diff --git a/AUTHORS b/AUTHORS new file mode 100644 index 000000000..da06cbada --- /dev/null +++ b/AUTHORS @@ -0,0 +1,6 @@ +# Below is a list of people and organizations that have contributed +# to the Charts project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..6258dd668 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,68 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution, +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +* If you are a Googler, it is preferable to first create an internal CL and + have it reviewed and submitted. The code propagation process will deliver + the change to GitHub. + +* Create **small PRs** that are narrowly focused on **addressing a single + concern**. We often receive PRs that are trying to fix several things at a + time, but if only one fix is considered acceptable, nothing gets merged and + both author's & review's time is wasted. Create more PRs to address + different concerns and everyone will be happy. + +* For speculative, behavioral, or design changes, consider opening an + [Dart Charts issue](https://github.com/google/charts/issues) and + discussing it first. + +* Provide a good **PR description** as a record of **what** change is being + made and **why** it was made. Link to a GitHub issue if it exists. + +* Don't fix code style and formatting unless you are already changing that + line to address an issue. PRs with irrelevant changes won't be merged. If + you do want to fix formatting or style, do that in a separate PR. + +* Unless your PR is trivial, you should expect there will be reviewer comments + that you'll need to address before merging. We expect you to be reasonably + responsive to those comments, otherwise the PR will be closed after 2-3 + weeks of inactivity. + +* Maintain **clean commit history** and use **meaningful commit messages**. + PRs with messy commit history are difficult to review and won't be merged. + Use `rebase -i upstream/master` to curate your commit history and/or to + bring in latest changes from master (but avoid rebasing in the middle of a + code review). + +* Keep your PR up to date with upstream/master (if there are merge conflicts, + we can't really merge your change). + +* Exceptions to the rules can be made if there's a compelling reason for doing + so. That is - the rules are here to serve us, not the other way around, and + the rules need to be serving their intended purpose to be valuable. + +* All submissions, including submissions by project members, require review. + +## Dart Charts Committers + +The current members of the Dart Charts engineering team are the only +committers at present. + +## Release Process + +Dart Charts lives at head, where latest-and-greatest code can be found. diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index e69de29bb..9345252a5 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,16 @@ +Charts is a general charting library, currently enabled for the +[Flutter mobile app toolkit](https://flutter.io). + +*Note*: This is not an official Google product. + +## charts_common + +A common library for charting packages. + +## charts_flutter + +A charting package for [Flutter](https://flutter.io), supporting both Android +and iOS. + +All charts packages are licensed under the Apache 2 license, see the +[LICENSE](LICENSE) and [AUTHORS](AUTHORS) files for details. diff --git a/charts_common/README.md b/charts_common/README.md new file mode 100644 index 000000000..607a5e8c3 --- /dev/null +++ b/charts_common/README.md @@ -0,0 +1,3 @@ +# Common Charting library + +Common componnets for charting libraries. diff --git a/charts_common/lib/common.dart b/charts_common/lib/common.dart new file mode 100644 index 000000000..370dffb29 --- /dev/null +++ b/charts_common/lib/common.dart @@ -0,0 +1,83 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'src/chart/common/behavior/chart_behavior.dart' show ChartBehavior; +export 'src/chart/common/behavior/a11y/a11y_explore_behavior.dart' + show ExploreModeTrigger; +export 'src/chart/common/behavior/a11y/a11y_node.dart' show A11yNode; +export 'src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart' + show DomainA11yExploreBehavior, VocalizationCallback; +export 'src/chart/common/behavior/zoom/pan_and_zoom_behavior.dart' + show PanAndZoomBehavior; +export 'src/chart/common/behavior/zoom/pan_behavior.dart' show PanBehavior; +export 'src/chart/common/chart_canvas.dart' show ChartCanvas, FillPatternType; +export 'src/chart/common/base_chart.dart' show BaseChart, LifecycleListener; +export 'src/chart/common/canvas_shapes.dart' show CanvasBarStack, CanvasRect; +export 'src/chart/common/chart_context.dart' show ChartContext; +export 'src/chart/common/series_renderer.dart' show SeriesRenderer; +export 'src/chart/common/series_renderer_config.dart' + show RendererAttributeKey, SeriesRendererConfig; +export 'src/chart/common/behavior/chart_behavior.dart' show ChartBehavior; +export 'src/chart/common/behavior/domain_highlighter.dart' + show DomainHighlighter; +export 'src/chart/common/behavior/legend/legend.dart' + show SeriesLegend, LegendState; +export 'src/chart/common/behavior/legend/legend_entry.dart' show LegendEntry; +export 'src/chart/common/behavior/line_point_highlighter.dart' + show LinePointHighlighter; +export 'src/chart/common/behavior/range_annotation.dart' + show RangeAnnotation, RangeAnnotationAxisType, RangeAnnotationSegment; +export 'src/chart/common/behavior/select_nearest.dart' + show SelectNearest, SelectNearestTrigger; +export 'src/chart/common/selection_model/selection_model.dart' + show SelectionModel, SelectionModelType, SelectionModelListener; +export 'src/chart/layout/layout_view.dart' show LayoutPosition, ViewMargin; +export 'src/chart/layout/layout_config.dart' show LayoutConfig, MarginSpec; +export 'src/common/color.dart' show Color; +export 'src/common/date_time_factory.dart' + show DateTimeFactory, LocalDateTimeFactory, UTCDateTimeFactory; +export 'src/common/gesture_listener.dart' show GestureListener; +export 'src/common/graphics_factory.dart' show GraphicsFactory; +export 'src/common/material_palette.dart' show MaterialPalette; +export 'src/common/performance.dart' show Performance; +export 'src/common/proxy_gesture_listener.dart' show ProxyGestureListener; +export 'src/common/rtl_spec.dart' show RTLSpec, AxisPosition; +export 'src/common/line_style.dart' show LineStyle; +export 'src/common/symbol_renderer.dart' + show SymbolRenderer, RoundedRectSymbolRenderer; +export 'src/common/style/style_factory.dart' show StyleFactory; +export 'src/common/style/material_style.dart' show MaterialStyle; +export 'src/common/style/quantum_style.dart' show QuantumStyle; +export 'src/common/text_element.dart' + show TextElement, TextDirection, MaxWidthStrategy; +export 'src/common/text_measurement.dart' show TextMeasurement; +export 'src/common/text_style.dart' show TextStyle; +export 'src/common/quantum_palette.dart' show QuantumPalette; +export 'src/data/series.dart' show Series; +export 'src/chart/bar/bar_chart.dart' show BarChart; +export 'src/chart/bar/bar_renderer.dart' + show BarRenderer, ImmutableBarRendererElement; +export 'src/chart/bar/bar_renderer_config.dart' show BarRendererConfig; +export 'src/chart/bar/bar_renderer_decorator.dart' show BarRendererDecorator; +export 'src/chart/bar/bar_label_decorator.dart' show BarLabelDecorator; +export 'src/chart/bar/base_bar_renderer_config.dart' + show BarGroupingType, BaseBarRendererConfig; +export 'src/chart/cartesian/axis/spec/axis_spec.dart' show AxisSpec; +export 'src/chart/cartesian/cartesian_chart.dart' show CartesianChart; +export 'src/chart/cartesian/cartesian_renderer.dart' show BaseCartesianRenderer; +export 'src/chart/line/line_chart.dart' show LineChart; +export 'src/chart/line/line_renderer.dart' show LineRenderer; +export 'src/chart/line/line_renderer_config.dart' show LineRendererConfig; +export 'src/chart/time_series/time_series_chart.dart' show TimeSeriesChart; diff --git a/charts_common/lib/src/chart/bar/bar_chart.dart b/charts_common/lib/src/chart/bar/bar_chart.dart new file mode 100644 index 000000000..6855ef5b8 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_chart.dart @@ -0,0 +1,30 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../bar/bar_renderer.dart' show BarRenderer; +import '../cartesian/cartesian_chart.dart' show OrdinalCartesianChart; +import '../common/series_renderer.dart' show SeriesRenderer; +import '../layout/layout_config.dart' show LayoutConfig; + +class BarChart extends OrdinalCartesianChart { + BarChart({bool vertical, LayoutConfig layoutConfig}) + : super(vertical: vertical, layoutConfig: layoutConfig); + + @override + SeriesRenderer makeDefaultRenderer() { + return new BarRenderer() + ..rendererId = SeriesRenderer.defaultRendererId; + } +} diff --git a/charts_common/lib/src/chart/bar/bar_label_decorator.dart b/charts_common/lib/src/chart/bar/bar_label_decorator.dart new file mode 100644 index 000000000..7dc916350 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_label_decorator.dart @@ -0,0 +1,190 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:meta/meta.dart' show required; +import '../cartesian/axis/spec/axis_spec.dart' show TextStyleSpec; +import '../common/chart_canvas.dart' show ChartCanvas; +import '../../common/color.dart' show Color; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../../common/text_element.dart' show TextDirection; +import '../../common/text_style.dart' show TextStyle; +import 'bar_renderer.dart' show ImmutableBarRendererElement; +import 'bar_renderer_decorator.dart' show BarRendererDecorator; + +class BarLabelDecorator extends BarRendererDecorator { + // Default configuration + static const _defaultLabelPosition = BarLabelPosition.auto; + static const _defaultLabelPadding = 5; + static const _defaultLabelAnchor = BarLabelAnchor.start; + static final _defaultInsideLabelStyle = + new TextStyleSpec(fontSize: 12, color: Color.white); + static final _defaultOutsideLabelStyle = + new TextStyleSpec(fontSize: 12, color: Color.black); + + /// Configures [TextStyleSpec] for labels placed inside the bars. + final TextStyleSpec insideLabelStyleSpec; + + /// Configures [TextStyleSpec] for labels placed outside the bars. + final TextStyleSpec outsideLabelStyleSpec; + + /// Configures where to place the label relative to the bars. + final BarLabelPosition labelPosition; + + /// For labels drawn inside the bar, configures label anchor position. + final BarLabelAnchor labelAnchor; + + /// Space before and after the label text. + final int labelPadding; + + BarLabelDecorator( + {TextStyleSpec insideLabelStyleSpec, + TextStyleSpec outsideLabelStyleSpec, + this.labelPosition: _defaultLabelPosition, + this.labelPadding: _defaultLabelPadding, + this.labelAnchor: _defaultLabelAnchor}) + : insideLabelStyleSpec = insideLabelStyleSpec ?? _defaultInsideLabelStyle, + outsideLabelStyleSpec = + outsideLabelStyleSpec ?? _defaultOutsideLabelStyle; + + @override + void decorate(Iterable barElements, + ChartCanvas canvas, GraphicsFactory graphicsFactory, + {@required Rectangle drawBounds, + @required double animationPercent, + @required bool renderingVertically, + bool rtl: false}) { + // TODO: Decorator not yet available for vertical charts. + assert(renderingVertically == false); + + // Only decorate the bars when animation is at 100%. + if (animationPercent != 1.0) { + return; + } + + // Create [TextStyle] from [TextStyleSpec] to be used by all the elements. + // The [GraphicsFactory] is needed so it can't be created earlier. + final insideLabelStyle = + _getTextStyle(graphicsFactory, insideLabelStyleSpec); + final outsideLabelStyle = + _getTextStyle(graphicsFactory, outsideLabelStyleSpec); + + for (ImmutableBarRendererElement element in barElements) { + final labelFn = element.series.labelAccessorFn; + final label = (labelFn != null) ? labelFn(element.datum, null) : null; + + // Skip calculation and drawing for this element if no label. + if (label == null || label.isEmpty) { + continue; + } + + final bounds = element.bounds; + + // Get space available inside and outside the bar. + final totalPadding = labelPadding * 2; + final insideBarWidth = bounds.width - totalPadding; + final outsideBarWidth = drawBounds.width - bounds.width - totalPadding; + + final labelElement = graphicsFactory.createTextElement(label); + var calculatedLabelPosition = labelPosition; + if (calculatedLabelPosition == BarLabelPosition.auto) { + // For auto, first try to fit the text inside the bar. + labelElement.textStyle = insideLabelStyle; + + // A label fits if the space inside the bar is >= outside bar or if the + // length of the text fits and the space. This is because if the bar has + // more space than the outside, it makes more sense to place the label + // inside the bar, even if the entire label does not fit. + calculatedLabelPosition = (insideBarWidth >= outsideBarWidth || + labelElement.measurement.horizontalSliceWidth < insideBarWidth) + ? BarLabelPosition.inside + : BarLabelPosition.outside; + } + + // Set the max width and text style. + if (calculatedLabelPosition == BarLabelPosition.inside) { + labelElement.textStyle = insideLabelStyle; + labelElement.maxWidth = insideBarWidth; + } else { + // calculatedLabelPosition == LabelPosition.outside + labelElement.textStyle = outsideLabelStyle; + labelElement.maxWidth = outsideBarWidth; + } + + // Only calculate and draw label if there's actually space for the label. + if (labelElement.maxWidth > 0) { + // Calculate the start position of label based on [labelAnchor]. + int labelX; + if (calculatedLabelPosition == BarLabelPosition.inside) { + final alignLeft = rtl + ? (labelAnchor == BarLabelAnchor.end) + : (labelAnchor == BarLabelAnchor.start); + + if (alignLeft) { + labelX = bounds.left + labelPadding; + labelElement.textDirection = TextDirection.ltr; + } else { + labelX = bounds.right - labelPadding; + labelElement.textDirection = TextDirection.rtl; + } + } else { + // calculatedLabelPosition == LabelPosition.outside + labelX = bounds.right + labelPadding; + labelElement.textDirection = TextDirection.ltr; + } + + // Center the label inside the bar. + final labelY = (bounds.top + + (bounds.bottom - bounds.top) / 2 - + labelElement.measurement.verticalSliceWidth / 2) + .round(); + + canvas.drawText(labelElement, labelX, labelY); + } + } + } + + // Helper function that converts [TextStyleSpec] to [TextStyle]. + TextStyle _getTextStyle( + GraphicsFactory graphicsFactory, TextStyleSpec labelSpec) { + return graphicsFactory.createTextPaint() + ..color = labelSpec?.color ?? Color.black + ..fontFamily = labelSpec?.fontFamily + ..fontSize = labelSpec?.fontSize ?? 12; + } +} + +/// Configures where to place the label relative to the bars. +enum BarLabelPosition { + /// Automatically try to place the label inside the bar first and place it on + /// the outside of the space available outside the bar is greater than space + /// available inside the bar. + auto, + + /// Always place label on the outside. + outside, + + /// Always place label on the inside. + inside, +} + +/// Configures where to anchor the label for labels drawn inside the bars. +enum BarLabelAnchor { + /// Anchor to the measure start. + start, + + /// Anchor to the measure end. + end, +} diff --git a/charts_common/lib/src/chart/bar/bar_renderer.dart b/charts_common/lib/src/chart/bar/bar_renderer.dart new file mode 100644 index 000000000..a04e7b7ea --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_renderer.dart @@ -0,0 +1,345 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show max, min, Rectangle; +import 'package:meta/meta.dart' show required; + +import 'bar_renderer_config.dart' show BarRendererConfig; +import 'bar_renderer_decorator.dart' show BarRendererDecorator; +import 'base_bar_renderer.dart' show BaseBarRenderer; +import 'base_bar_renderer_element.dart' + show BaseAnimatedBar, BaseBarRendererElement; +import '../cartesian/axis/axis.dart' show ImmutableAxis; +import '../common/base_chart.dart' show BaseChart; +import '../common/canvas_shapes.dart' show CanvasBarStack, CanvasRect; +import '../common/chart_canvas.dart' show ChartCanvas, FillPatternType; +import '../common/processed_series.dart' show ImmutableSeries, MutableSeries; +import '../../common/color.dart' show Color; + +/// Renders series data as a series of bars. +class BarRenderer + extends BaseBarRenderer> { + /// If we are grouped, use this spacing between the bars in a group. + final _barGroupInnerPadding = 2; + + /// The radius to use for corner rounding. + final _roundingRadiusPx = 2; + + /// The padding between bar stacks. + /// + /// The padding comes out of the bottom of the bar. + final _stackedBarPadding = 1; + + BaseChart _chart; + + final BarRendererDecorator barRendererDecorator; + + factory BarRenderer({BarRendererConfig config, String rendererId}) { + rendererId ??= 'bar'; + config ??= new BarRendererConfig(); + return new BarRenderer._internal(config: config, rendererId: rendererId); + } + + BarRenderer._internal({BarRendererConfig config, String rendererId}) + : barRendererDecorator = config.barRendererDecorator, + super(config: config, rendererId: rendererId, layoutPositionOrder: 10); + + @override + void preprocessSeries(List> seriesList) { + assignMissingColors(getOrderedSeriesList(seriesList), + emptyCategoryUsesSinglePalette: true); + + super.preprocessSeries(seriesList); + } + + @override + _BarRendererElement getBaseDetails(T datum, int index) { + return new _BarRendererElement()..roundPx = _roundingRadiusPx; + } + + bool get rtl => _chart.context.rtl; + + /// Generates an [_AnimatedBar] to represent the previous and current state + /// of one bar on the chart. + @override + _AnimatedBar makeAnimatedBar( + {String key, + ImmutableSeries series, + T datum, + Color color, + _BarRendererElement details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + FillPatternType fillPattern, + double strokeWidthPx, + int barGroupIndex, + int numBarGroups}) { + return new _AnimatedBar( + key: key, datum: datum, series: series, domainValue: domainValue) + ..setNewTarget(makeBarRendererElement( + color: color, + details: details, + domainValue: domainValue, + domainAxis: domainAxis, + domainWidth: domainWidth, + measureValue: measureValue, + measureOffsetValue: measureOffsetValue, + measureAxisPosition: measureAxisPosition, + measureAxis: measureAxis, + fillPattern: fillPattern, + strokeWidthPx: strokeWidthPx, + barGroupIndex: barGroupIndex, + numBarGroups: numBarGroups)); + } + + /// Generates a [_BarRendererElement] to represent the rendering data for one + /// bar on the chart. + @override + _BarRendererElement makeBarRendererElement( + {Color color, + _BarRendererElement details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + FillPatternType fillPattern, + double strokeWidthPx, + int barGroupIndex, + int numBarGroups}) { + return new _BarRendererElement() + ..color = color + ..roundPx = details.roundPx + ..measureAxisPosition = measureAxisPosition + ..fillPattern = fillPattern + ..strokeWidthPx = strokeWidthPx + ..bounds = _getBarBounds( + domainValue, + domainAxis, + domainWidth, + measureValue, + measureOffsetValue, + measureAxis, + barGroupIndex, + numBarGroups); + } + + @override + void onAttach(BaseChart chart) { + super.onAttach(chart); + // We only need the chart.context.rtl setting, but context is not yet + // available when the default renderer is attached to the chart on chart + // creation time, since chart onInit is called after the chart is created. + _chart = chart; + } + + @override + void paintBar(ChartCanvas canvas, double animationPercent, + Iterable<_BarRendererElement> barElements) { + final bars = []; + + // When adjusting bars for stacked bar padding, do not modify the first bar + // if rendering vertically and do not modify the last bar if rendering + // horizontally. + final unmodifiedBar = + renderingVertically ? barElements.first : barElements.last; + + for (_BarRendererElement bar in barElements) { + var bounds = bar.bounds; + + if (bar != unmodifiedBar) { + bounds = renderingVertically + ? new Rectangle( + bar.bounds.left, + bar.bounds.top, + bar.bounds.width, + max(0, bar.bounds.height - _stackedBarPadding), + ) + : new Rectangle( + bar.bounds.left, + bar.bounds.top, + max(0, bar.bounds.width - _stackedBarPadding), + bar.bounds.height, + ); + } + + bars.add(new CanvasRect(bounds, + fill: bar.color, pattern: bar.fillPattern, stroke: bar.color)); + } + + // TODO: Change _roundingRadiusPx to use CornerRadiusCalculator + // in BarRendererConfig. Also need to have a way to configure the calculator + canvas.drawBarStack(new CanvasBarStack( + bars, + radius: _roundingRadiusPx, + stackedBarPadding: _stackedBarPadding, + roundTopLeft: renderingVertically || rtl ? true : false, + roundTopRight: rtl ? false : true, + roundBottomLeft: rtl ? true : false, + roundBottomRight: renderingVertically || rtl ? false : true, + )); + + // Decorate the bar segments if there is a decorator. + barRendererDecorator?.decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: animationPercent, + renderingVertically: renderingVertically, + rtl: rtl); + } + + /// Generates a set of bounds that describe a bar. + Rectangle _getBarBounds( + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + int barGroupIndex, + int numBarGroups) { + // Calculate how wide each bar should be within the group of bars. If we + // only have one series, or are stacked, then barWidth should equal + // domainWidth. + int spacingLoss = (_barGroupInnerPadding * (numBarGroups - 1)); + int barWidth = ((domainWidth - spacingLoss) / numBarGroups).round(); + + // Flip bar group index for calculating location on the domain axis if RTL. + final adjustedBarGroupIndex = + rtl ? numBarGroups - barGroupIndex - 1 : barGroupIndex; + + // Calculate the start and end of the bar, taking into account accumulated + // padding for grouped bars. + int domainStart = (domainAxis.getLocation(domainValue) - + (domainWidth / 2) + + (barWidth + _barGroupInnerPadding) * adjustedBarGroupIndex) + .round(); + + int domainEnd = domainStart + barWidth; + + measureValue = measureValue != null ? measureValue : 0; + + // Calculate measure locations. Stacked bars should have their + // offset calculated previously. + int measureStart = measureAxis.getLocation(measureOffsetValue).round(); + int measureEnd = + measureAxis.getLocation(measureValue + measureOffsetValue).round(); + + var bounds; + if (this.renderingVertically) { + bounds = new Rectangle(domainStart, measureEnd, + domainEnd - domainStart, measureStart - measureEnd); + } else { + bounds = new Rectangle(min(measureStart, measureEnd), domainStart, + (measureEnd - measureStart).abs(), domainEnd - domainStart); + } + return bounds; + } + + @override + Rectangle getBoundsForBar(_BarRendererElement bar) => bar.bounds; +} + +abstract class ImmutableBarRendererElement { + ImmutableSeries get series; + D get datum; + Rectangle get bounds; +} + +class _BarRendererElement extends BaseBarRendererElement + implements ImmutableBarRendererElement { + ImmutableSeries series; + D datum; + Rectangle bounds; + int roundPx; + + _BarRendererElement(); + + _BarRendererElement.clone(_BarRendererElement other) : super.clone(other) { + series = other.series; + datum = other.datum; + bounds = other.bounds; + roundPx = other.roundPx; + } + + @override + void updateAnimationPercent(BaseBarRendererElement previous, + BaseBarRendererElement target, double animationPercent) { + final _BarRendererElement localPrevious = previous; + final _BarRendererElement localTarget = target; + + final previousBounds = localPrevious.bounds; + final targetBounds = localTarget.bounds; + + var top = ((targetBounds.top - previousBounds.top) * animationPercent) + + previousBounds.top; + var right = + ((targetBounds.right - previousBounds.right) * animationPercent) + + previousBounds.right; + var bottom = + ((targetBounds.bottom - previousBounds.bottom) * animationPercent) + + previousBounds.bottom; + var left = ((targetBounds.left - previousBounds.left) * animationPercent) + + previousBounds.left; + + bounds = new Rectangle(left.round(), top.round(), + (right - left).round(), (bottom - top).round()); + + roundPx = localTarget.roundPx; + + super.updateAnimationPercent(previous, target, animationPercent); + } +} + +class _AnimatedBar extends BaseAnimatedBar { + _AnimatedBar( + {@required String key, + @required T datum, + @required ImmutableSeries series, + @required D domainValue}) + : super(key: key, datum: datum, series: series, domainValue: domainValue); + + @override + animateElementToMeasureAxisPosition(BaseBarRendererElement target) { + final _BarRendererElement localTarget = target; + + // TODO: Animate out bars in the middle of a stack. + localTarget.bounds = new Rectangle( + localTarget.bounds.left + (localTarget.bounds.width / 2).round(), + localTarget.measureAxisPosition.round(), + 0, + 0); + } + + _BarRendererElement getCurrentBar(double animationPercent) { + final _BarRendererElement bar = super.getCurrentBar(animationPercent); + + // Update with series and datum information to pass to bar decorator. + bar.series = series; + bar.datum = datum; + + return bar; + } + + @override + _BarRendererElement clone(_BarRendererElement other) => + new _BarRendererElement.clone(other); +} diff --git a/charts_common/lib/src/chart/bar/bar_renderer_config.dart b/charts_common/lib/src/chart/bar/bar_renderer_config.dart new file mode 100644 index 000000000..3f8fe1029 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_renderer_config.dart @@ -0,0 +1,80 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'base_bar_renderer_config.dart' + show BarGroupingType, BaseBarRendererConfig; +import 'bar_renderer.dart' show BarRenderer; +import 'bar_renderer_decorator.dart' show BarRendererDecorator; +import '../common/chart_canvas.dart' show FillPatternType; +import '../../common/symbol_renderer.dart'; + +/// Configuration for a bar renderer. +class BarRendererConfig extends BaseBarRendererConfig { + /// Calculator for determining the corner radius of a bar. + final CornerRadiusCalculator cornerRadiusCalculator; + + /// Decorator for optionally decorating painted bars. + final BarRendererDecorator barRendererDecorator; + + BarRendererConfig({ + String customRendererId, + List barWeights, + this.cornerRadiusCalculator, + FillPatternType fillPattern, + BarGroupingType groupingType, + int minBarLengthPx = 0, + double stackHorizontalSeparator, + double strokeWidthPx = 0.0, + this.barRendererDecorator, + SymbolRenderer symbolRenderer, + }) : super( + customRendererId: customRendererId, + barWeights: barWeights, + groupingType: groupingType ?? BarGroupingType.grouped, + minBarLengthPx: minBarLengthPx, + fillPattern: fillPattern, + stackHorizontalSeparator: stackHorizontalSeparator, + strokeWidthPx: strokeWidthPx, + symbolRenderer: symbolRenderer); + + @override + BarRenderer build() { + return new BarRenderer( + config: this, rendererId: customRendererId); + } + + @override + bool operator ==(o) { + if (identical(this, o)) { + return true; + } + if (!(o is BarRendererConfig)) { + return false; + } + return o.cornerRadiusCalculator == cornerRadiusCalculator && super == (o); + } + + @override + int get hashCode { + var hash = super.hashCode; + hash = hash * 31 + (cornerRadiusCalculator?.hashCode ?? 0); + return hash; + } +} + +abstract class CornerRadiusCalculator { + /// Returns the radius of the rounded corners in pixels. + int getRadius(int barWidth); +} diff --git a/charts_common/lib/src/chart/bar/bar_renderer_decorator.dart b/charts_common/lib/src/chart/bar/bar_renderer_decorator.dart new file mode 100644 index 000000000..f8d78d666 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_renderer_decorator.dart @@ -0,0 +1,32 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:meta/meta.dart' show required; +import '../common/chart_canvas.dart' show ChartCanvas; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import 'bar_renderer.dart' show ImmutableBarRendererElement; + +/// Decorates bars after the bars have already been painted. +abstract class BarRendererDecorator { + const BarRendererDecorator(); + + void decorate(Iterable barElements, + ChartCanvas canvas, GraphicsFactory graphicsFactory, + {@required Rectangle drawBounds, + @required double animationPercent, + @required bool renderingVertically, + bool rtl: false}); +} diff --git a/charts_common/lib/src/chart/bar/bar_target_line_renderer.dart b/charts_common/lib/src/chart/bar/bar_target_line_renderer.dart new file mode 100644 index 000000000..ef692df56 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_target_line_renderer.dart @@ -0,0 +1,328 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point, Rectangle, max, min; +import 'package:meta/meta.dart' show required; + +import '../cartesian/axis/axis.dart' show ImmutableAxis; + +import 'bar_target_line_renderer_config.dart' show BarTargetLineRendererConfig; +import 'base_bar_renderer.dart' show BaseBarRenderer; +import 'base_bar_renderer_element.dart' + show BaseAnimatedBar, BaseBarRendererElement; +import '../common/chart_canvas.dart' show ChartCanvas, FillPatternType; +import '../common/processed_series.dart' show ImmutableSeries, MutableSeries; +import '../../common/color.dart' show Color; + +/// Renders series data as a series of bar target lines. +/// +/// Usually paired with a BarRenderer to display target metrics alongside actual +/// metrics. +class BarTargetLineRenderer extends BaseBarRenderer> { + /// If we are grouped, use this spacing between the bars in a group. + final _barGroupInnerPadding = 2; + + /// Standard color for all bar target lines. + final _color = new Color(r: 0, g: 0, b: 0, a: 153); + + factory BarTargetLineRenderer( + {BarTargetLineRendererConfig config, + String rendererId = 'barTargetLine'}) { + config ??= new BarTargetLineRendererConfig(); + return new BarTargetLineRenderer._internal( + config: config, rendererId: rendererId); + } + + BarTargetLineRenderer._internal( + {BarTargetLineRendererConfig config, String rendererId}) + : super(config: config, rendererId: rendererId, layoutPositionOrder: 11); + + void preprocessSeries(List> seriesList) { + seriesList.forEach((MutableSeries series) { + series.colorFn ??= (T datum, int index) => _color; + }); + + super.preprocessSeries(seriesList); + } + + @override + _BarTargetLineRendererElement getBaseDetails(T datum, int index) { + final BarTargetLineRendererConfig localConfig = config; + return new _BarTargetLineRendererElement() + ..roundEndCaps = localConfig.roundEndCaps; + } + + /// Generates an [_AnimatedBarTargetLine] to represent the previous and + /// current state of one bar target line on the chart. + @override + _AnimatedBarTargetLine makeAnimatedBar( + {String key, + ImmutableSeries series, + T datum, + Color color, + _BarTargetLineRendererElement details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + FillPatternType fillPattern, + int barGroupIndex, + int numBarGroups, + double strokeWidthPx}) { + return new _AnimatedBarTargetLine( + key: key, datum: datum, series: series, domainValue: domainValue) + ..setNewTarget(makeBarRendererElement( + color: color, + details: details, + domainValue: domainValue, + domainAxis: domainAxis, + domainWidth: domainWidth, + measureValue: measureValue, + measureOffsetValue: measureOffsetValue, + measureAxisPosition: measureAxisPosition, + measureAxis: measureAxis, + fillPattern: fillPattern, + strokeWidthPx: strokeWidthPx, + barGroupIndex: barGroupIndex, + numBarGroups: numBarGroups)); + } + + /// Generates a [_BarTargetLineRendererElement] to represent the rendering + /// data for one bar target line on the chart. + @override + _BarTargetLineRendererElement makeBarRendererElement( + {Color color, + _BarTargetLineRendererElement details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + FillPatternType fillPattern, + double strokeWidthPx, + int barGroupIndex, + int numBarGroups}) { + return new _BarTargetLineRendererElement() + ..color = color + ..roundEndCaps = details.roundEndCaps + ..measureAxisPosition = measureAxisPosition + ..fillPattern = fillPattern + ..strokeWidthPx = strokeWidthPx + ..points = _getTargetLinePoints( + domainValue, + domainAxis, + domainWidth, + measureValue, + measureOffsetValue, + measureAxis, + barGroupIndex, + numBarGroups); + } + + @override + void paintBar( + ChartCanvas canvas, + double animationPercent, + Iterable<_BarTargetLineRendererElement> barElements, + ) { + barElements.forEach((_BarTargetLineRendererElement bar) { + // TODO: Combine common line attributes into + // GraphicsFactory.lineStyle or similar. + canvas.drawLine( + points: bar.points, + stroke: bar.color, + roundEndCaps: bar.roundEndCaps, + strokeWidthPx: bar.strokeWidthPx); + }); + } + + /// Generates a set of points that describe a bar target line. + List> _getTargetLinePoints( + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + int barGroupIndex, + int numBarGroups) { + final BarTargetLineRendererConfig localConfig = config; + + // Calculate how wide each bar target line should be within the group of + // bar target lines. If we only have one series, or are stacked, then + // barWidth should equal domainWidth. + int spacingLoss = (_barGroupInnerPadding * (numBarGroups - 1)); + int barWidth = ((domainWidth - spacingLoss) / numBarGroups).round(); + + // Get the overdraw boundaries. + var overDrawOuterPx = localConfig.overDrawOuterPx; + var overDrawPx = localConfig.overDrawPx; + + int overDrawStartPx = (barGroupIndex == 0) && overDrawOuterPx != null + ? overDrawOuterPx + : overDrawPx; + + int overDrawEndPx = + (barGroupIndex == numBarGroups - 1) && overDrawOuterPx != null + ? overDrawOuterPx + : overDrawPx; + + // Calculate the start and end of the bar target line, taking into account + // accumulated padding for grouped bars. + int domainStart = (domainAxis.getLocation(domainValue) - + (domainWidth / 2) + + (barWidth + _barGroupInnerPadding) * barGroupIndex - + overDrawStartPx) + .round(); + + int domainEnd = domainStart + barWidth + overDrawStartPx + overDrawEndPx; + + measureValue = measureValue != null ? measureValue : 0; + + // Calculate measure locations. Stacked bars should have their + // offset calculated previously. + int measureStart = + measureAxis.getLocation(measureValue + measureOffsetValue).round(); + + var points; + if (renderingVertically) { + points = [ + new Point(domainStart, measureStart), + new Point(domainEnd, measureStart) + ]; + } else { + points = [ + new Point(measureStart, domainStart), + new Point(measureStart, domainEnd) + ]; + } + return points; + } + + @override + Rectangle getBoundsForBar(_BarTargetLineRendererElement bar) { + final points = bar.points; + int top; + int bottom; + int left; + int right; + points.forEach((Point p) { + top = top != null ? min(top, p.y) : p.y; + left = left != null ? min(left, p.x) : p.x; + bottom = bottom != null ? max(bottom, p.y) : p.y; + right = right != null ? max(right, p.x) : p.x; + }); + return new Rectangle(left, top, right - left, bottom - top); + } +} + +class _BarTargetLineRendererElement extends BaseBarRendererElement { + List> points; + bool roundEndCaps; + + _BarTargetLineRendererElement(); + + _BarTargetLineRendererElement.clone(_BarTargetLineRendererElement other) + : super.clone(other) { + points = other.points; + roundEndCaps = other.roundEndCaps; + } + + @override + void updateAnimationPercent(BaseBarRendererElement previous, + BaseBarRendererElement target, double animationPercent) { + final _BarTargetLineRendererElement localPrevious = previous; + final _BarTargetLineRendererElement localTarget = target; + + final previousPoints = localPrevious.points; + final targetPoints = localTarget.points; + + Point lastPoint; + + var pointIndex; + for (pointIndex = 0; pointIndex < targetPoints.length; pointIndex++) { + var targetPoint = targetPoints[pointIndex]; + + // If we have more points than the previous line, animate in the new point + // by starting its measure position at the last known official point. + var previousPoint; + if (previousPoints.length - 1 >= pointIndex) { + previousPoint = previousPoints[pointIndex]; + lastPoint = previousPoint; + } else { + previousPoint = new Point(targetPoint.x, lastPoint.y); + } + + var x = ((targetPoint.x - previousPoint.x) * animationPercent) + + previousPoint.x; + + var y = ((targetPoint.y - previousPoint.y) * animationPercent) + + previousPoint.y; + + if (points.length - 1 >= pointIndex) { + points[pointIndex] = new Point(x.round(), y.round()); + } else { + points.add(new Point(x.round(), y.round())); + } + } + + // Removing extra points that don't exist anymore. + if (pointIndex < points.length) { + points.removeRange(pointIndex, points.length); + } + + strokeWidthPx = ((localTarget.strokeWidthPx - localPrevious.strokeWidthPx) * + animationPercent) + + localPrevious.strokeWidthPx; + + roundEndCaps = localTarget.roundEndCaps; + + super.updateAnimationPercent(previous, target, animationPercent); + } +} + +class _AnimatedBarTargetLine + extends BaseAnimatedBar { + _AnimatedBarTargetLine( + {@required String key, + @required T datum, + @required ImmutableSeries series, + @required D domainValue}) + : super(key: key, datum: datum, series: series, domainValue: domainValue); + + @override + animateElementToMeasureAxisPosition(BaseBarRendererElement target) { + final _BarTargetLineRendererElement localTarget = target; + + final newPoints = []; + for (var index = 0; index < localTarget.points.length; index++) { + final targetPoint = localTarget.points[index]; + + newPoints.add(new Point( + targetPoint.x, localTarget.measureAxisPosition.round())); + } + localTarget.points = newPoints; + } + + @override + _BarTargetLineRendererElement clone(_BarTargetLineRendererElement other) => + new _BarTargetLineRendererElement.clone(other); +} diff --git a/charts_common/lib/src/chart/bar/bar_target_line_renderer_config.dart b/charts_common/lib/src/chart/bar/bar_target_line_renderer_config.dart new file mode 100644 index 000000000..2cbdcb940 --- /dev/null +++ b/charts_common/lib/src/chart/bar/bar_target_line_renderer_config.dart @@ -0,0 +1,87 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'base_bar_renderer_config.dart' + show BarGroupingType, BaseBarRendererConfig; +import 'bar_target_line_renderer.dart' show BarTargetLineRenderer; +import '../common/chart_canvas.dart' show FillPatternType; + +/// Configuration for a bar target line renderer. +class BarTargetLineRendererConfig extends BaseBarRendererConfig { + /// The number of pixels that the line will extend beyond the bandwidth at the + /// edges of the bar group. + /// + /// If set, this overrides overDrawPx for the beginning side of the first bar + /// target line in the group, and the ending side of the last bar target line. + /// overDrawPx will be used for overdrawing the target lines for interior + /// sides of the bars. + final int overDrawOuterPx; + + /// The number of pixels that the line will extend beyond the bandwidth for + /// every bar in a group. + final int overDrawPx; + + /// Whether target lines should have round end caps, or square if false. + final bool roundEndCaps; + + BarTargetLineRendererConfig( + {String customRendererId, + List barWeights, + groupingType = BarGroupingType.grouped, + int minBarLengthPx = 0, + this.overDrawOuterPx, + this.overDrawPx = 0, + FillPatternType fillPattern, + this.roundEndCaps = true, + double stackHorizontalSeparator, + double strokeWidthPx = 3.0}) + : super( + customRendererId: customRendererId, + barWeights: barWeights, + groupingType: groupingType, + minBarLengthPx: minBarLengthPx, + fillPattern: fillPattern, + stackHorizontalSeparator: stackHorizontalSeparator, + strokeWidthPx: strokeWidthPx); + + @override + BarTargetLineRenderer build() { + return new BarTargetLineRenderer( + config: this, rendererId: customRendererId); + } + + @override + bool operator ==(o) { + if (identical(this, o)) { + return true; + } + if (!(o is BarTargetLineRendererConfig)) { + return false; + } + return o.overDrawOuterPx == overDrawOuterPx && + o.overDrawPx == overDrawPx && + o.roundEndCaps == roundEndCaps && + super == (o); + } + + @override + int get hashCode { + var hash = 1; + hash = hash * 31 + (overDrawOuterPx?.hashCode ?? 0); + hash = hash * 31 + (overDrawPx?.hashCode ?? 0); + hash = hash * 31 + (roundEndCaps?.hashCode ?? 0); + return hash; + } +} diff --git a/charts_common/lib/src/chart/bar/base_bar_renderer.dart b/charts_common/lib/src/chart/bar/base_bar_renderer.dart new file mode 100644 index 000000000..83b65b8d2 --- /dev/null +++ b/charts_common/lib/src/chart/bar/base_bar_renderer.dart @@ -0,0 +1,614 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show LinkedHashMap; +import 'dart:math' show Point, Rectangle, max; +import 'package:meta/meta.dart' show protected; + +import 'base_bar_renderer_config.dart' show BaseBarRendererConfig; +import 'base_bar_renderer_element.dart' + show BaseAnimatedBar, BaseBarRendererElement; +import '../cartesian/cartesian_renderer.dart' show BaseCartesianRenderer; +import '../cartesian/axis/axis.dart' + show ImmutableAxis, domainAxisKey, measureAxisKey; +import '../common/chart_canvas.dart' show ChartCanvas, FillPatternType; +import '../common/datum_details.dart' show DatumDetails; +import '../common/processed_series.dart' show ImmutableSeries, MutableSeries; +import '../../common/color.dart' show Color; +import '../../common/symbol_renderer.dart' show SymbolRenderer; +import '../../data/series.dart' show AttributeKey; + +const barGroupIndexKey = const AttributeKey('BarRenderer.barGroupIndex'); + +const barGroupCountKey = const AttributeKey('BarRenderer.barGroupCount'); + +const stackKeyKey = const AttributeKey('BarRenderer.stackKey'); + +const barElementsKey = + const AttributeKey>('BarRenderer.elements'); + +/// Base class for bar renderers that implements common stacking and grouping +/// logic. +/// +/// Bar renderers support 4 different modes of rendering multiple series on the +/// chart, configured by the grouped and stacked flags. +/// * grouped - Render bars for each series that shares a domain value +/// side-by-side. +/// * stacked - Render bars for each series that shares a domain value in a +/// stack, ordered in the same order as the series list. +/// * grouped-stacked: Render bars for each series that shares a domain value in +/// a group of bar stacks. Each stack will contain all the series that share a +/// series category. +/// * floating style - When grouped and stacked are both false, all bars that +/// share a domain value will be rendered in the same domain space. Each datum +/// should be configured with a measure offset to position its bar along the +/// measure axis. Bars will freely overlap if their measure values and measure +/// offsets overlap. Note that bars for each series will be rendered in order, +/// such that bars from the last series will be "on top" of bars from previous +/// series. +abstract class BaseBarRenderer> extends BaseCartesianRenderer { + final BaseBarRendererConfig config; + + /// Store a map of domain+barGroupIndex+category index to bars in a stack. + /// + /// This map is used to render all the bars in a stack together, to account + /// for rendering effects that need to take the full stack into account (e.g. + /// corner rounding). + /// + /// [LinkedHashMap] is used to render the bars on the canvas in the same order + /// as the data was given to the chart. For the case where both grouping and + /// stacking are disabled, this means that bars for data later in the series + /// will be drawn "on top of" bars earlier in the series. + final _barStackMap = new LinkedHashMap>(); + + // Store a list of bar stacks that exist in the series data. + // + // This list will be used to remove any AnimatingBars that were rendered in + // previous draw cycles, but no longer have a corresponding datum in the new + // data. + final _currentKeys = []; + + /// Stores a list of stack keys for each group key. + final _currentGroupsStackKeys = new LinkedHashMap>(); + + /// Optimization for getNearest to avoid scanning all data if possible. + ImmutableAxis _prevDomainAxis; + + BaseBarRenderer( + {BaseBarRendererConfig config, + String rendererId, + int layoutPositionOrder}) + : this.config = config, + super(rendererId: rendererId, layoutPositionOrder: layoutPositionOrder); + + @override + SymbolRenderer get symbolRenderer => config.symbolRenderer; + + void preprocessSeries(List> seriesList) { + var barGroupIndex = 0; + + // Maps used to store the final measure offset of the previous series, for + // each domain value. + final posDomainToStackKeyToDetailsMap = {}; + final negDomainToStackKeyToDetailsMap = {}; + final categoryToIndexMap = {}; + + // Keep track of the largest bar stack size. This should be 1 for grouped + // bars, and it should be the size of the tallest stack for stacked or + // grouped stacked bars. + var maxBarStackSize = 0; + + final orderedSeriesList = getOrderedSeriesList(seriesList); + + orderedSeriesList.forEach((MutableSeries series) { + var elements = []; + + var domainFn = series.domainFn; + var measureFn = series.measureFn; + var measureOffsetFn = series.measureOffsetFn; + var fillPatternFn = series.fillPatternFn; + var strokeWidthPxFn = series.strokeWidthPxFn; + + // Identifies which stack the series will go in, by default a single + // stack. + var stackKey = '__defaultKey__'; + + // Override the stackKey with seriesCategory if we are GROUPED_STACKED + // so we have a way to choose which series go into which stacks. + if (config.grouped && config.stacked) { + if (series.seriesCategory != null) { + stackKey = series.seriesCategory; + } + + barGroupIndex = categoryToIndexMap[stackKey]; + if (barGroupIndex == null) { + barGroupIndex = categoryToIndexMap.length; + categoryToIndexMap[stackKey] = barGroupIndex; + } + } + + var needsMeasureOffset = false; + + for (var barIndex = 0; barIndex < series.data.length; barIndex++) { + T datum = series.data[barIndex]; + final details = getBaseDetails(datum, barIndex); + + details.barStackIndex = 0; + details.measureOffset = 0; + + if (fillPatternFn != null) { + details.fillPattern = fillPatternFn(datum, barIndex); + } else { + details.fillPattern = config.fillPattern; + } + + if (strokeWidthPxFn != null) { + details.strokeWidthPx = strokeWidthPxFn(datum, barIndex).toDouble(); + } else { + details.strokeWidthPx = config.strokeWidthPx; + } + + // When stacking is enabled, adjust the measure offset for each domain + // value in each series by adding up the measures and offsets of lower + // series. + if (config.stacked) { + needsMeasureOffset = true; + var domain = domainFn(datum, barIndex); + var measure = measureFn(datum, barIndex); + + // We will render positive bars in one stack, and negative bars in a + // separate stack. Keep track of the measure offsets for these stacks + // independently. + var domainToCategoryToDetailsMap = measure == null || measure >= 0 + ? posDomainToStackKeyToDetailsMap + : negDomainToStackKeyToDetailsMap; + + var categoryToDetailsMap = + domainToCategoryToDetailsMap.putIfAbsent(domain, () => {}); + + var prevDetail = categoryToDetailsMap[stackKey]; + + if (prevDetail != null) { + details.barStackIndex = prevDetail.barStackIndex + 1; + } + + details.cumulativeTotal = measure != null ? measure : 0; + + // Get the previous series' measure offset. + var measureOffset = measureOffsetFn(datum, barIndex); + if (prevDetail != null) { + measureOffset += prevDetail.measureOffsetPlusMeasure; + + details.cumulativeTotal += prevDetail.cumulativeTotal; + } + + // And overwrite the details measure offset. + details.measureOffset = measureOffset; + var measureValue = (measure != null ? measure : 0); + details.measureOffsetPlusMeasure = measureOffset + measureValue; + + categoryToDetailsMap[stackKey] = details; + } + + maxBarStackSize = max(maxBarStackSize, details.barStackIndex + 1); + + elements.add(details); + } + + if (needsMeasureOffset) { + // Override the measure offset function to return the measure offset we + // calculated for each datum. This already includes any measure offset + // that was configured in the series data. + series.measureOffsetFn = + (datum, index) => elements[index].measureOffset; + } + + series.setAttr(barGroupIndexKey, barGroupIndex); + series.setAttr(stackKeyKey, stackKey); + series.setAttr(barElementsKey, elements); + + if (config.grouped) { + barGroupIndex++; + } + }); + + // Compute number of bar groups. + var numBarGroups = 0; + if (config.grouped && config.stacked) { + // For grouped stacked bars, categoryToIndexMap effectively one list per + // group of stacked bars. + numBarGroups = categoryToIndexMap.length; + } else if (config.stacked) { + numBarGroups = 1; + } else { + numBarGroups = seriesList.length; + } + + seriesList.forEach((MutableSeries series) { + series.setAttr(barGroupCountKey, numBarGroups); + }); + } + + /// Construct a base details element for a given datum. + /// + /// This is intended to be overridden by child classes that need to add + /// customized rendering properties. + R getBaseDetails(T datum, int index); + + @override + void configureDomainAxes(List> seriesList) { + super.configureDomainAxes(seriesList); + + // TODO: tell axis that we some rangeBand configuration. + } + + void update( + List> seriesList, bool isAnimatingThisDraw) { + var numBarGroups = config.stacked ? 1 : seriesList.length; + + _currentKeys.clear(); + _currentGroupsStackKeys.clear(); + + final orderedSeriesList = getOrderedSeriesList(seriesList); + + orderedSeriesList.forEach((final ImmutableSeries series) { + var domainAxis = series.getAttr(domainAxisKey) as ImmutableAxis; + var domainFn = series.domainFn; + var measureAxis = series.getAttr(measureAxisKey) as ImmutableAxis; + var measureFn = series.measureFn; + var colorFn = series.colorFn; + var seriesStackKey = series.getAttr(stackKeyKey); + var barGroupIndex = series.getAttr(barGroupIndexKey); + var measureAxisPosition = measureAxis.getLocation(0.0); + + var elementsList = series.getAttr(barElementsKey); + + // Save off domainAxis for getNearest. + _prevDomainAxis = domainAxis; + + for (var barIndex = 0; barIndex < series.data.length; barIndex++) { + T datum = series.data[barIndex]; + BaseBarRendererElement details = elementsList[barIndex]; + D domainValue = domainFn(datum, barIndex); + + // Each bar should be stored in barStackMap in a structure that mirrors + // the visual rendering of the bars. Thus, they should be grouped by + // domain value, series category (by way of the stack keys that were + // generated for each series in the preprocess step), and bar group + // index to account for all combinations of grouping and stacking. + var barStackMapKey = domainValue.toString() + + '__' + + seriesStackKey + + '__' + + barGroupIndex.toString(); + + var barKey = barStackMapKey + details.barStackIndex.toString(); + + var barStackList = _barStackMap.putIfAbsent(barStackMapKey, () => []); + + // If we already have a AnimatingBar for that index, use it. + var animatingBar = barStackList.firstWhere((B bar) => bar.key == barKey, + orElse: () => null); + + // If we don't have any existing bar element, create a new bar and have + // it animate in from the domain axis. + // TODO: Animate bars in the middle of a stack from their + // nearest neighbors, instead of the measure axis. + if (animatingBar == null) { + animatingBar = makeAnimatedBar( + key: barKey, + series: series, + datum: datum, + barGroupIndex: barGroupIndex, + color: colorFn(datum, barIndex), + details: details, + domainValue: domainFn(datum, barIndex), + domainAxis: domainAxis, + domainWidth: domainAxis.rangeBand.round(), + fillPattern: details.fillPattern, + measureValue: 0.0, + measureOffsetValue: 0.0, + measureAxisPosition: measureAxisPosition, + measureAxis: measureAxis, + numBarGroups: numBarGroups, + strokeWidthPx: details.strokeWidthPx); + + barStackList.add(animatingBar); + } else { + animatingBar + ..datum = datum + ..series = series + ..domainValue = domainValue; + } + + // Update the set of bars that still exist in the series data. + _currentKeys.add(barKey); + + // Store off stack keys for each bar group to help getNearest identify + // groups of stacks. + _currentGroupsStackKeys + .putIfAbsent(domainValue, () => new Set()) + .add(barStackMapKey); + + // Get the barElement we are going to setup. + // Optimization to prevent allocation in non-animating case. + BaseBarRendererElement barElement = makeBarRendererElement( + barGroupIndex: series.getAttr(barGroupIndexKey), + color: colorFn(datum, barIndex), + details: details, + domainValue: domainFn(datum, barIndex), + domainAxis: domainAxis, + domainWidth: domainAxis.rangeBand.round(), + fillPattern: details.fillPattern, + measureValue: measureFn(datum, barIndex), + measureOffsetValue: details.measureOffset, + measureAxisPosition: measureAxisPosition, + measureAxis: measureAxis, + numBarGroups: series.getAttr(barGroupCountKey), + strokeWidthPx: details.strokeWidthPx); + + animatingBar.setNewTarget(barElement); + } + + if (config.grouped) { + barGroupIndex++; + } + }); + + // Animate out bars that don't exist anymore. + _barStackMap.forEach((String key, List barStackList) { + for (var barIndex = 0; barIndex < barStackList.length; barIndex++) { + var bar = barStackList[barIndex]; + + if (_currentKeys.contains(bar.key) != true) { + bar.animateOut(); + } + } + }); + } + + /// Generates a [BaseAnimatedBar] to represent the previous and current state + /// of one bar on the chart. + B makeAnimatedBar( + {String key, + ImmutableSeries series, + T datum, + int barGroupIndex, + Color color, + R details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + int numBarGroups, + FillPatternType fillPattern, + double strokeWidthPx}); + + /// Generates a [BaseBarRendererElement] to represent the rendering data for + /// one bar on the chart. + R makeBarRendererElement( + {int barGroupIndex, + Color color, + R details, + D domainValue, + ImmutableAxis domainAxis, + int domainWidth, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis, + double measureAxisPosition, + int numBarGroups, + FillPatternType fillPattern, + double strokeWidthPx}); + + /// Paints the current bar data on the canvas. + void paint(ChartCanvas canvas, double animationPercent) { + // Clean up the bars that no longer exist. + if (animationPercent == 1.0) { + final keysToRemove = []; + + _barStackMap.forEach((String key, List barStackList) { + barStackList.retainWhere((B bar) => !bar.animatingOut); + + if (barStackList.isEmpty) { + keysToRemove.add(key); + } + }); + + keysToRemove.forEach((String key) => _barStackMap.remove(key)); + } + + _barStackMap.forEach((String stackKey, List barStack) { + final barElements = barStack.map( + (B animatingBar) => animatingBar.getCurrentBar(animationPercent)); + + paintBar(canvas, animationPercent, barElements); + }); + } + + /// Paints a stack of bar elements on the canvas. + void paintBar( + ChartCanvas canvas, double animationPercent, Iterable barElements); + + @override + List> getNearestDatumDetailPerSeries( + Point chartPoint) { + // Was it even in the drawArea? + if (!componentBounds.containsPoint(chartPoint)) { + return >[]; + } + + final domainValue = _prevDomainAxis + .getDomain(renderingVertically ? chartPoint.x : chartPoint.y); + + List> nearest; + + // If we have a domainValue for the event point, then find all segments + // that match it. + if (domainValue != null) { + if (renderingVertically) { + nearest = _getVerticalDetailsForDomainValue(domainValue, chartPoint); + } else { + nearest = _getHorizontalDetailsForDomainValue(domainValue, chartPoint); + } + } + + // If we didn't find anything, then keep an empty list. + nearest ??= >[]; + + // Note: the details are already sorted by domain & measure distance in + // base chart. + + return nearest; + } + + Rectangle getBoundsForBar(R bar); + + @protected + List> _getSegmentsForDomainValue(D domainValue, + {bool where(BaseAnimatedBar bar)}) { + final matchingSegments = >[]; + + final stackKeys = _currentGroupsStackKeys[domainValue]; + stackKeys?.forEach((String stackKey) { + if (where != null) { + matchingSegments.addAll(_barStackMap[stackKey].where(where)); + } else { + matchingSegments.addAll(_barStackMap[stackKey]); + } + }); + + return matchingSegments; + } + + List> _getVerticalDetailsForDomainValue( + D domainValue, Point chartPoint) { + return new List>.from(_getSegmentsForDomainValue( + domainValue, + where: (BaseAnimatedBar bar) => !bar.series.overlaySeries) + .map>((BaseAnimatedBar bar) { + final barBounds = getBoundsForBar(bar.currentBar); + final segmentDomainDistance = + _getDistance(chartPoint.x.round(), barBounds.left, barBounds.right); + final segmentMeasureDistance = + _getDistance(chartPoint.y.round(), barBounds.top, barBounds.bottom); + + return new DatumDetails( + series: bar.series, + datum: bar.datum, + domain: bar.domainValue, + domainDistance: segmentDomainDistance, + measureDistance: segmentMeasureDistance, + ); + })); + } + + List> _getHorizontalDetailsForDomainValue( + D domainValue, Point chartPoint) { + return new List>.from(_getSegmentsForDomainValue( + domainValue, + where: (BaseAnimatedBar bar) => !bar.series.overlaySeries) + .map((BaseAnimatedBar bar) { + final barBounds = getBoundsForBar(bar.currentBar); + final segmentDomainDistance = + _getDistance(chartPoint.y.round(), barBounds.top, barBounds.bottom); + final segmentMeasureDistance = + _getDistance(chartPoint.x.round(), barBounds.left, barBounds.right); + + return new DatumDetails( + series: bar.series, + datum: bar.datum, + domain: bar.domainValue, + domainDistance: segmentDomainDistance, + measureDistance: segmentMeasureDistance, + ); + })); + } + + double _getDistance(int point, int min, int max) { + if (max >= point && min <= point) { + return 0.0; + } + return (point > max ? (point - max) : (min - point)).toDouble(); + } + + /// Gets the iterator for the series based grouped/stacked and orientation. + /// + /// For vertical stacked bars: + /// * If grouped, return the iterator that keeps the category order but + /// reverse the order of the series so the first series is on the top of the + /// stack. + /// * Otherwise, return iterator of the reversed list + /// + /// All other types, use the in order iterator. + @protected + Iterable getOrderedSeriesList( + List seriesList) { + return (renderingVertically && config.stacked) + ? config.grouped + ? new _ReversedSeriesIterable(seriesList) + : seriesList.reversed + : seriesList; + } +} + +/// Iterable wrapping the seriesList that returns the ReversedSeriesItertor. +class _ReversedSeriesIterable extends Iterable { + final List seriesList; + + _ReversedSeriesIterable(this.seriesList); + + @override + Iterator get iterator => new _ReversedSeriesIterator(seriesList); +} + +/// Iterator that keeps reverse series order but keeps category order. +/// +/// This is needed because for grouped stacked bars, the category stays in the +/// order it was passed in for the grouping, but the series is flipped so that +/// the first series of that category is on the top of the stack. +class _ReversedSeriesIterator extends Iterator { + final List _list; + final _visitIndex = []; + int _current; + + _ReversedSeriesIterator(List list) : _list = list { + // In the order of the list, save the category and the indices of the series + // with the same category. + final categoryAndSeriesIndexMap = >{}; + for (var i = 0; i < list.length; i++) { + categoryAndSeriesIndexMap + .putIfAbsent(list[i].seriesCategory, () => []) + .add(i); + } + + // Creates a visit that is categories in order, but the series is reversed. + categoryAndSeriesIndexMap + .forEach((_, indices) => _visitIndex.addAll(indices.reversed)); + } + @override + bool moveNext() { + _current = (_current == null) ? 0 : _current + 1; + + return _current < _list.length; + } + + @override + S get current => _list[_visitIndex[_current]]; +} diff --git a/charts_common/lib/src/chart/bar/base_bar_renderer_config.dart b/charts_common/lib/src/chart/bar/base_bar_renderer_config.dart new file mode 100644 index 000000000..11f63e78b --- /dev/null +++ b/charts_common/lib/src/chart/bar/base_bar_renderer_config.dart @@ -0,0 +1,124 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/equality.dart' show ListEquality; +import '../common/chart_canvas.dart' show FillPatternType; +import '../common/series_renderer_config.dart' + show RendererAttributes, SeriesRendererConfig; +import '../layout/layout_view.dart' show LayoutViewConfig; +import '../../common/symbol_renderer.dart' show SymbolRenderer; + +/// Shared configuration for bar chart renderers. +/// +/// Bar renderers support 4 different modes of rendering multiple series on the +/// chart, configured by the grouped and stacked flags. +/// * grouped - Render bars for each series that shares a domain value +/// side-by-side. +/// * stacked - Render bars for each series that shares a domain value in a +/// stack, ordered in the same order as the series list. +/// * grouped-stacked: Render bars for each series that shares a domain value in +/// a group of bar stacks. Each stack will contain all the series that share a +/// series category. +/// * floating style - When grouped and stacked are both false, all bars that +/// share a domain value will be rendered in the same domain space. Each datum +/// should be configured with a measure offset to position its bar along the +/// measure axis. Bars will freely overlap if their measure values and measure +/// offsets overlap. Note that bars for each series will be rendered in order, +/// such that bars from the last series will be "on top" of bars from previous +/// series. +abstract class BaseBarRendererConfig extends LayoutViewConfig + implements SeriesRendererConfig { + final String customRendererId; + + final SymbolRenderer symbolRenderer; + + final List barWeights; + + /// Defines the way multiple series of bars are rendered per domain. + final BarGroupingType groupingType; + + final int minBarLengthPx; + + final FillPatternType fillPattern; + + final double stackHorizontalSeparator; + + /// Stroke width of the target line. + final double strokeWidthPx; + + final rendererAttributes = new RendererAttributes(); + + BaseBarRendererConfig( + {this.customRendererId, + this.barWeights, + this.groupingType = BarGroupingType.grouped, + this.minBarLengthPx = 0, + this.fillPattern, + this.stackHorizontalSeparator, + this.strokeWidthPx = 0.0, + this.symbolRenderer}); + + /// Whether or not the bars should be organized into groups. + bool get grouped => + groupingType == BarGroupingType.grouped || + groupingType == BarGroupingType.groupedStacked; + + /// Whether or not the bars should be organized into stacks. + bool get stacked => + groupingType == BarGroupingType.stacked || + groupingType == BarGroupingType.groupedStacked; + + @override + bool operator ==(o) { + if (identical(this, o)) { + return true; + } + if (!(o is BaseBarRendererConfig)) { + return false; + } + return o.customRendererId == customRendererId && + new ListEquality().equals(o.barWeights, barWeights) && + o.fillPattern == fillPattern && + o.groupingType == groupingType && + o.minBarLengthPx == minBarLengthPx && + o.stackHorizontalSeparator == stackHorizontalSeparator && + o.strokeWidthPx == strokeWidthPx && + o.symbolRenderer == symbolRenderer; + } + + int get hashcode { + var hash = 1; + hash = hash * 31 + (customRendererId?.hashCode ?? 0); + hash = hash * 31 + (barWeights?.hashCode ?? 0); + hash = hash * 31 + (fillPattern?.hashCode ?? 0); + hash = hash * 31 + (groupingType?.hashCode ?? 0); + hash = hash * 31 + (minBarLengthPx?.hashCode ?? 0); + hash = hash * 31 + (stackHorizontalSeparator?.hashCode ?? 0); + hash = hash * 31 + (strokeWidthPx?.hashCode ?? 0); + hash = hash * 31 + (symbolRenderer?.hashCode ?? 0); + return hash; + } +} + +/// Defines the way multiple series of bars are renderered per domain. +/// +/// * [grouped] - Render bars for each series that shares a domain value +/// side-by-side. +/// * [stacked] - Render bars for each series that shares a domain value in a +/// stack, ordered in the same order as the series list. +/// * [groupedStacked]: Render bars for each series that shares a domain value +/// in a group of bar stacks. Each stack will contain all the series that +/// share a series category. +enum BarGroupingType { grouped, groupedStacked, stacked } diff --git a/charts_common/lib/src/chart/bar/base_bar_renderer_element.dart b/charts_common/lib/src/chart/bar/base_bar_renderer_element.dart new file mode 100644 index 000000000..56e296b52 --- /dev/null +++ b/charts_common/lib/src/chart/bar/base_bar_renderer_element.dart @@ -0,0 +1,110 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../common/chart_canvas.dart' show getAnimatedColor, FillPatternType; +import '../common/processed_series.dart' show ImmutableSeries; +import '../../common/color.dart' show Color; + +abstract class BaseBarRendererElement { + int barStackIndex; + Color color; + num cumulativeTotal; + FillPatternType fillPattern; + double measureAxisPosition; + num measureOffset; + num measureOffsetPlusMeasure; + double strokeWidthPx; + + BaseBarRendererElement(); + + BaseBarRendererElement.clone(BaseBarRendererElement other) { + barStackIndex = other.barStackIndex; + color = other.color; + cumulativeTotal = other.cumulativeTotal; + fillPattern = other.fillPattern; + measureAxisPosition = other.measureAxisPosition; + measureOffset = other.measureOffset; + measureOffsetPlusMeasure = other.measureOffsetPlusMeasure; + strokeWidthPx = other.strokeWidthPx; + } + + void updateAnimationPercent(BaseBarRendererElement previous, + BaseBarRendererElement target, double animationPercent) { + color = getAnimatedColor(previous.color, target.color, animationPercent); + } +} + +abstract class BaseAnimatedBar { + final String key; + T datum; + ImmutableSeries series; + D domainValue; + + R _previousBar; + R _targetBar; + R _currentBar; + + // Flag indicating whether this bar is being animated out of the chart. + bool animatingOut = false; + + BaseAnimatedBar({this.key, this.datum, this.series, this.domainValue}); + + /// Animates a bar that was removed from the series out of the view. + /// + /// This should be called in place of "setNewTarget" for bars that represent + /// data that has been removed from the series. + /// + /// Animates the height of the bar down to the measure axis position (position + /// of 0). Animates the width of the bar down to 0, centered in the middle of + /// the original bar width. + void animateOut() { + var newTarget = clone(_currentBar); + + animateElementToMeasureAxisPosition(newTarget); + + setNewTarget(newTarget); + animatingOut = true; + } + + /// Sets the bounds for the target to the measure axis position. + void animateElementToMeasureAxisPosition(R target); + + /// Sets a new element to render. + void setNewTarget(R newTarget) { + animatingOut = false; + _currentBar ??= clone(newTarget); + _previousBar = _currentBar; + _targetBar = newTarget; + } + + R get currentBar => _currentBar; + + /// Gets the new state of the bar element for painting, updated for a + /// transition between the previous state and the new animationPercent. + R getCurrentBar(double animationPercent) { + if (animationPercent == 1.0 || _previousBar == null) { + _currentBar = _targetBar; + _previousBar = _targetBar; + return _currentBar; + } + + _currentBar.updateAnimationPercent( + _previousBar, _targetBar, animationPercent); + + return _currentBar; + } + + R clone(R bar); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/axis.dart b/charts_common/lib/src/chart/cartesian/axis/axis.dart new file mode 100644 index 000000000..d82c53cc1 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/axis.dart @@ -0,0 +1,400 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle, min, max; + +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../data/series.dart' show AttributeKey; +import '../../common/chart_canvas.dart' show ChartCanvas; +import '../../common/chart_context.dart' show ChartContext; +import '../../layout/layout_view.dart' + show LayoutView, LayoutPosition, LayoutViewConfig, ViewMeasuredSizes; +import 'linear/linear_scale.dart' show LinearScale; +import 'ordinal_extents.dart' show OrdinalExtents; +import 'ordinal_scale.dart' show OrdinalScale; +import 'ordinal_tick_provider.dart' show OrdinalTickProvider; +import 'numeric_extents.dart' show NumericExtents; +import 'numeric_scale.dart' show NumericScale; +import 'numeric_tick_provider.dart' show NumericTickProvider; +import 'scale.dart' show MutableScale, ScaleOutputExtent, Scale, Extents; +import 'simple_ordinal_scale.dart' show SimpleOrdinalScale; +import 'tick.dart' show Tick; +import 'tick_formatter.dart' + show TickFormatter, OrdinalTickFormatter, NumericTickFormatter; +import 'tick_provider.dart' show TickProvider; +import 'draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import 'draw_strategy/small_tick_draw_strategy.dart' show SmallTickDrawStrategy; + +const measureAxisIdKey = const AttributeKey('Axis.measureAxisId'); +const measureAxisKey = const AttributeKey('Axis.measureAxis'); +const domainAxisKey = const AttributeKey('Axis.domainAxis'); + +/// Orientation of an Axis. +enum AxisOrientation { top, right, bottom, left } + +abstract class ImmutableAxis { + /// Compare domain to the viewport. + /// + /// 0 if the domain is in the viewport. + /// 1 if the domain is to the right of the viewport. + /// -1 if the domain is to the left of the viewport. + int compareDomainValueToViewport(D domain); + + /// Get location for the domain. + double getLocation(D domain); + + D getDomain(double location); + + /// Rangeband for this axis. + double get rangeBand; + + /// Step size for this axis. + double get stepSize; +} + +abstract class Axis> + extends ImmutableAxis implements LayoutView { + static const primaryMeasureAxisId = 'primaryMeasureAxisId'; + static const secondaryMeasureAxisId = 'secondaryMeasureAxisId'; + + /// [Scale] of this axis. + final S _scale; + + /// [TickProvider] for this axis. + TickProvider tickProvider; + + /// [TickFormatter] for this axis. + TickFormatter tickFormatter; + final _formatterValueCache = {}; + + /// [TickDrawStrategy] for this axis. + TickDrawStrategy tickDrawStrategy; + + /// [AxisOrienation] for this axis. + AxisOrientation axisOrientation; + + ChartContext context; + + /// If the output range should be reversed. + bool reverseOutputRange = false; + + /// Whether or not the axis will configure the viewport to have "niced" ticks + /// around the domain values. + bool _autoViewport = true; + + /// If the axis line should always be drawn. + bool forceDrawAxisLine; + + /// Used by Charts Common library implementation only. + /// + /// If true, ticks get updated location only. + /// + /// This is used in pan / zoom behavior for a domain axis to prevent ticks + /// from being recalculated for each drag update event between the start of a + /// drag and the end of a drag. + bool updateTickLocationOnly = false; + + /// If true, do not allow axis to be modified. + /// + /// Ticks (including their location) are not updated. + /// Viewport changes not allowed. + bool lockAxis = false; + + List _ticks; + + Rectangle _componentBounds; + Rectangle _drawAreaBounds; + GraphicsFactory _graphicsFactory; + + Axis({this.tickProvider, this.tickFormatter, S scale}) : this._scale = scale; + + /// Rangeband for this axis. + @override + double get rangeBand => _scale.rangeBand; + + @override + double get stepSize => _scale.stepSize; + + /// Ticks for this axis. + List get ticks => _ticks; + + /// Configures whether the viewport should be reset back to default values + /// when the domain is reset. + /// + /// This should generally be disabled when the viewport will be managed + /// externally, e.g. from pan and zoom behaviors. + set autoViewport(bool autoViewport) { + _autoViewport = autoViewport; + } + + bool get autoViewport => _autoViewport; + + void addDomainValue(D domain) { + if (lockAxis) { + return; + } + + _scale.addDomain(domain); + } + + void resetDomains() { + if (lockAxis) { + return; + } + + _scale.resetDomain(); + reverseOutputRange = false; + + if (_autoViewport) { + _scale.resetViewportSettings(); + } + + // TODO: Reset rangeband and step size when we port over config + //scale.rangeBandConfig = get range band config + //scale.stepSizeConfig = get step size config + } + + @override + double getLocation(D domain) => _scale[domain]; + + @override + D getDomain(double location) => _scale.reverse(location); + + @override + int compareDomainValueToViewport(D domain) { + return _scale.compareDomainValueToViewport(domain); + } + + void setOutputRange(int start, int end) { + _scale.range = new ScaleOutputExtent(start, end); + } + + void updateTicks() { + if (lockAxis) { + return; + } + + if (updateTickLocationOnly) { + _ticks.forEach((t) => t.locationPx = _scale[t.value]); + return; + } + + // TODO: Ensure that tick providers take manually configured + // viewport settings into account, so that we still get the right number. + _ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: _scale, + formatter: tickFormatter, + formatterValueCache: _formatterValueCache, + tickDrawStrategy: tickDrawStrategy, + orientation: axisOrientation, + viewportExtensionEnabled: _autoViewport); + } + + /// Configures the zoom and translate. + /// + /// [viewportScale] is the zoom factor to use, likely >= 1.0 where 1.0 maps + /// the complete data extents to the output range, and 2.0 only maps half the + /// data to the output range. + /// + /// [viewportTranslatePx] is the translate/pan to use in pixel units, + /// likely <= 0 which shifts the start of the data before the edge of the + /// chart giving us a pan. + /// + /// [drawAreaWidth] is the width of the draw area for the series data in pixel + /// units, at minimum viewport scale level (1.0). When provided, + /// [viewportTranslatePx] will be clamped such that the axis cannot be panned + /// beyond the bounds of the data. + void setViewportSettings(double viewportScale, double viewportTranslatePx, + {int drawAreaWidth}) { + // Don't let the viewport be panned beyond the bounds of the data. + viewportTranslatePx = _clampTranslatePx(viewportScale, viewportTranslatePx, + drawAreaWidth: drawAreaWidth); + + _scale.setViewportSettings(viewportScale, viewportTranslatePx); + } + + /// Returns the current viewport scale. + /// + /// A scale of 1.0 would map the data directly to the output range, while a + /// value of 2.0 would map the data to an output of double the range so you + /// only see half the data in the viewport. This is the equivalent to + /// zooming. Its value is likely >= 1.0. + double get viewportScalingFactor => _scale.viewportScalingFactor; + + /// Returns the current pixel viewport offset + /// + /// The translate is used by the scale function when it applies the scale. + /// This is the equivalent to panning. Its value is likely <= 0 to pan the + /// data to the left. + double get viewportTranslatePx => _scale?.viewportTranslatePx; + + /// Clamps a possible change in domain translation to fit within the range of + /// the data. + double _clampTranslatePx( + double viewportScalingFactor, double viewportTranslatePx, + {int drawAreaWidth}) { + if (drawAreaWidth == null) { + return viewportTranslatePx; + } + + // Bound the viewport translate to the range of the data. + final maxNegativeTranslate = + -1.0 * ((drawAreaWidth * viewportScalingFactor) - drawAreaWidth); + + viewportTranslatePx = + min(max(viewportTranslatePx, maxNegativeTranslate), 0.0); + + return viewportTranslatePx; + } + + // + // LayoutView methods. + // + + @override + GraphicsFactory get graphicsFactory => _graphicsFactory; + + @override + set graphicsFactory(GraphicsFactory value) { + _graphicsFactory = value; + } + + @override + LayoutViewConfig get layoutConfig => + new LayoutViewConfig(position: _layoutPosition, positionOrder: 0); + + /// Get layout position from axis orientation. + LayoutPosition get _layoutPosition { + LayoutPosition position; + switch (axisOrientation) { + case AxisOrientation.top: + position = LayoutPosition.Top; + break; + case AxisOrientation.right: + position = LayoutPosition.Right; + break; + case AxisOrientation.bottom: + position = LayoutPosition.Bottom; + break; + case AxisOrientation.left: + position = LayoutPosition.Left; + break; + } + + return position; + } + + /// The axis is rendered vertically. + bool get isVertical => + axisOrientation == AxisOrientation.left || + axisOrientation == AxisOrientation.right; + + @override + ViewMeasuredSizes measure(int maxWidth, int maxHeight) { + return isVertical + ? _measureVerticalAxis(maxWidth, maxHeight) + : _measureHorizontalAxis(maxWidth, maxHeight); + } + + ViewMeasuredSizes _measureVerticalAxis(int maxWidth, int maxHeight) { + setOutputRange(maxHeight, 0); + updateTicks(); + + return tickDrawStrategy.measureVerticallyDrawnTicks( + ticks, maxWidth, maxHeight); + } + + ViewMeasuredSizes _measureHorizontalAxis(int maxWidth, int maxHeight) { + setOutputRange(0, maxWidth); + updateTicks(); + + return tickDrawStrategy.measureHorizontallyDrawnTicks( + ticks, maxWidth, maxHeight); + } + + /// Layout this component. + @override + void layout(Rectangle componentBounds, Rectangle drawAreaBounds) { + _componentBounds = componentBounds; + _drawAreaBounds = drawAreaBounds; + + // Update the output range if it is different than the current one. + // This is necessary because during the measure cycle, the output range is + // set between zero and the max range available. On layout, the output range + // needs to be updated to account of the offset of the axis view. + + final outputStart = + isVertical ? _componentBounds.bottom : _componentBounds.left; + final outputEnd = + isVertical ? _componentBounds.top : _componentBounds.right; + + final outputRange = reverseOutputRange + ? new ScaleOutputExtent(outputEnd, outputStart) + : new ScaleOutputExtent(outputStart, outputEnd); + + if (_scale.range != outputRange) { + _scale.range = outputRange; + } + updateTicks(); + } + + @override + Rectangle get componentBounds => this._componentBounds; + + bool get drawAxisLine { + if (forceDrawAxisLine != null) { + return forceDrawAxisLine; + } + + return tickDrawStrategy is SmallTickDrawStrategy; + } + + @override + void paint(ChartCanvas canvas, double animationPercent) { + for (Tick tick in ticks) { + tickDrawStrategy.draw(canvas, tick, + orientation: axisOrientation, + axisBounds: _componentBounds, + drawAreaBounds: _drawAreaBounds); + } + + if (drawAxisLine) { + tickDrawStrategy.drawAxisLine(canvas, axisOrientation, _componentBounds); + } + } +} + +class NumericAxis extends Axis { + NumericAxis() + : super( + tickProvider: new NumericTickProvider(), + tickFormatter: new NumericTickFormatter(), + scale: new LinearScale(), + ); +} + +class OrdinalAxis extends Axis { + OrdinalAxis({ + TickDrawStrategy tickDrawStrategy, + TickProvider tickProvider, + TickFormatter tickFormatter, + }) : super( + tickProvider: tickProvider ?? const OrdinalTickProvider(), + tickFormatter: tickFormatter ?? const OrdinalTickFormatter(), + scale: new SimpleOrdinalScale(), + ); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/collision_report.dart b/charts_common/lib/src/chart/cartesian/axis/collision_report.dart new file mode 100644 index 000000000..82141c414 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/collision_report.dart @@ -0,0 +1,38 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import 'tick.dart' show Tick; + +/// A report that contains a list of ticks and if they collide. +class CollisionReport { + /// If [ticks] collide. + final bool ticksCollide; + + final List ticks; + + final bool alternateTicksUsed; + + CollisionReport( + {@required this.ticksCollide, + @required this.ticks, + bool alternateTicksUsed}) + : alternateTicksUsed = alternateTicksUsed ?? false; + + CollisionReport.empty() + : ticksCollide = false, + ticks = [], + alternateTicksUsed = false; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/draw_strategy/base_tick_draw_strategy.dart b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/base_tick_draw_strategy.dart new file mode 100644 index 000000000..6a8b140b0 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/base_tick_draw_strategy.dart @@ -0,0 +1,430 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable, protected, required; +import 'dart:math'; + +import '../../../../common/line_style.dart' show LineStyle; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../../common/rtl_spec.dart' show AxisPosition; +import '../../../../common/style/style_factory.dart' show StyleFactory; +import '../../../../common/text_element.dart' show TextDirection; +import '../../../../common/text_style.dart' show TextStyle; +import '../../../common/chart_canvas.dart' show ChartCanvas; +import '../../../common/chart_context.dart' show ChartContext; +import '../../../layout/layout_view.dart' show ViewMeasuredSizes; +import '../axis.dart' show AxisOrientation; +import '../collision_report.dart' show CollisionReport; +import '../spec/axis_spec.dart' + show + TextStyleSpec, + TickLabelAnchor, + TickLabelJustification, + LineStyleSpec, + RenderSpec; +import '../tick.dart' show Tick; +import 'tick_draw_strategy.dart' show TickDrawStrategy; + +@immutable +abstract class BaseRenderSpec implements RenderSpec { + final TextStyleSpec labelStyle; + final TickLabelAnchor labelAnchor; + final TickLabelJustification labelJustification; + + final int labelOffsetFromAxisPx; + + /// Absolute distance from the tick to the text if using start/end + final int labelOffsetFromTickPx; + + final int minimumPaddingBetweenLabelsPx; + + final LineStyleSpec axisLineStyle; + + BaseRenderSpec({ + this.labelStyle, + this.labelAnchor, + this.labelJustification, + this.labelOffsetFromAxisPx, + this.labelOffsetFromTickPx, + this.minimumPaddingBetweenLabelsPx, + this.axisLineStyle, + }); + + @override + bool operator ==(Object other) { + return other is BaseRenderSpec && + labelStyle == other.labelStyle && + labelAnchor == other.labelAnchor && + labelJustification == other.labelJustification && + labelOffsetFromTickPx == other.labelOffsetFromTickPx && + labelOffsetFromAxisPx == other.labelOffsetFromAxisPx && + minimumPaddingBetweenLabelsPx == other.minimumPaddingBetweenLabelsPx && + axisLineStyle == other.axisLineStyle; + } + + @override + int get hashCode { + int hashcode = labelStyle?.hashCode ?? 0; + hashcode = (hashcode * 37) + labelAnchor?.hashCode ?? 0; + hashcode = (hashcode * 37) + labelJustification?.hashCode ?? 0; + hashcode = (hashcode * 37) + labelOffsetFromTickPx?.hashCode ?? 0; + hashcode = (hashcode * 37) + labelOffsetFromAxisPx?.hashCode ?? 0; + hashcode = (hashcode * 37) + minimumPaddingBetweenLabelsPx?.hashCode ?? 0; + hashcode = (hashcode * 37) + axisLineStyle?.hashCode ?? 0; + return hashcode; + } +} + +/// Base strategy that draws tick labels and checks for label collisions. +abstract class BaseTickDrawStrategy implements TickDrawStrategy { + final ChartContext chartContext; + + LineStyle axisLineStyle; + TextStyle labelStyle; + TickLabelAnchor tickLabelAnchor; + TickLabelJustification tickLabelJustification; + int labelOffsetFromAxisPx; + int labelOffsetFromTickPx; + + int minimumPaddingBetweenLabelsPx; + + BaseTickDrawStrategy(this.chartContext, GraphicsFactory graphicsFactory, + {TextStyleSpec labelStyleSpec, + LineStyleSpec axisLineStyleSpec, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + int minimumPaddingBetweenLabelsPx}) { + labelStyle = (graphicsFactory.createTextPaint() + ..color = labelStyleSpec?.color ?? StyleFactory.style.tickColor + ..fontFamily = labelStyleSpec?.fontFamily + ..fontSize = labelStyleSpec?.fontSize ?? 12); + + axisLineStyle = graphicsFactory.createLinePaint() + ..color = axisLineStyleSpec?.color ?? labelStyle.color + ..strokeWidth = axisLineStyleSpec?.thickness ?? 1; + + tickLabelAnchor = labelAnchor ?? TickLabelAnchor.centered; + tickLabelJustification = + labelJustification ?? TickLabelJustification.inside; + this.labelOffsetFromAxisPx = labelOffsetFromAxisPx ?? 5; + this.labelOffsetFromTickPx = labelOffsetFromTickPx ?? 5; + this.minimumPaddingBetweenLabelsPx = minimumPaddingBetweenLabelsPx ?? 50; + } + + @override + void decorateTicks(List> ticks) { + for (Tick tick in ticks) { + // If no style at all, set the default style. + if (tick.textElement.textStyle == null) { + tick.textElement.textStyle = labelStyle; + } else { + //Fill in whatever is missing + tick.textElement.textStyle.color ??= labelStyle.color; + tick.textElement.textStyle.fontFamily ??= labelStyle.fontFamily; + tick.textElement.textStyle.fontSize ??= labelStyle.fontSize; + } + } + } + + @override + CollisionReport collides(List> ticks, AxisOrientation orientation) { + // If there are no ticks, they do not collide. + if (ticks == null) { + return new CollisionReport( + ticksCollide: false, ticks: ticks, alternateTicksUsed: false); + } + + final vertical = orientation == AxisOrientation.left || + orientation == AxisOrientation.right; + + // First sort ticks by smallest locationPx first (NOT sorted by value). + // This allows us to only check if a tick collides with the previous tick. + ticks.sort((a, b) { + if (a.locationPx < b.locationPx) { + return -1; + } else if (a.locationPx > b.locationPx) { + return 1; + } else { + return 0; + } + }); + + double previousEnd = double.NEGATIVE_INFINITY; + bool collides = false; + + for (final tick in ticks) { + final tickSize = tick.textElement.measurement; + + if (vertical) { + final adjustedHeight = + tickSize.verticalSliceWidth + minimumPaddingBetweenLabelsPx; + + if (tickLabelAnchor == TickLabelAnchor.inside) { + if (identical(tick, ticks.first)) { + // Top most tick draws down from the location + collides = false; + previousEnd = tick.locationPx + adjustedHeight; + } else if (identical(tick, ticks.last)) { + // Bottom most tick draws up from the location + collides = previousEnd > tick.locationPx - adjustedHeight; + previousEnd = tick.locationPx; + } else { + // All other ticks is centered. + final halfHeight = adjustedHeight / 2; + collides = previousEnd > tick.locationPx - halfHeight; + previousEnd = tick.locationPx + halfHeight; + } + } else { + collides = previousEnd > tick.locationPx; + previousEnd = tick.locationPx + adjustedHeight; + } + } else { + // Use the text direction the ticks specified, unless the label anchor + // is set to [TickLabelAnchor.inside]. When 'inside' is set, the text + // direction is normalized such that the left most tick is drawn ltr, + // the last tick is drawn rtl, and all other ticks are in the center. + // This is not set until it is painted, so collision check needs to get + // the value also. + final textDirection = _normalizeHorizontalAnchor( + tickLabelAnchor, + chartContext.rtl, + identical(tick, ticks.first), + identical(tick, ticks.last)); + final adjustedWidth = + tickSize.horizontalSliceWidth + minimumPaddingBetweenLabelsPx; + switch (textDirection) { + case TextDirection.ltr: + collides = previousEnd > tick.locationPx; + previousEnd = tick.locationPx + adjustedWidth; + break; + case TextDirection.rtl: + collides = previousEnd > (tick.locationPx - adjustedWidth); + previousEnd = tick.locationPx; + break; + case TextDirection.center: + final halfWidth = adjustedWidth / 2; + collides = previousEnd > tick.locationPx - halfWidth; + previousEnd = tick.locationPx + halfWidth; + + break; + } + } + + if (collides) { + return new CollisionReport( + ticksCollide: true, ticks: ticks, alternateTicksUsed: false); + } + } + + return new CollisionReport( + ticksCollide: false, ticks: ticks, alternateTicksUsed: false); + } + + @override + ViewMeasuredSizes measureVerticallyDrawnTicks( + List> ticks, int maxWidth, int maxHeight) { + // TODO: Add spacing to account for the distance between the + // text and the axis baseline (even if it isn't drawn). + final maxHorizontalSliceWidth = ticks + .fold( + 0.0, + (double prevMax, tick) => max( + prevMax, + tick.textElement.measurement.horizontalSliceWidth + + labelOffsetFromAxisPx)) + .round(); + + return new ViewMeasuredSizes( + preferredWidth: maxHorizontalSliceWidth, preferredHeight: maxHeight); + } + + @override + ViewMeasuredSizes measureHorizontallyDrawnTicks( + List> ticks, int maxWidth, int maxHeight) { + final maxVerticalSliceWidth = ticks + .fold( + 0.0, + (double prevMax, tick) => + max(prevMax, tick.textElement.measurement.verticalSliceWidth)) + .round(); + + return new ViewMeasuredSizes( + preferredWidth: maxWidth, + preferredHeight: maxVerticalSliceWidth + labelOffsetFromAxisPx); + } + + @override + void drawAxisLine(ChartCanvas canvas, AxisOrientation orientation, + Rectangle axisBounds) { + Point start; + Point end; + + switch (orientation) { + case AxisOrientation.top: + start = axisBounds.bottomLeft; + end = axisBounds.bottomRight; + break; + case AxisOrientation.bottom: + start = axisBounds.topLeft; + end = axisBounds.topRight; + break; + case AxisOrientation.right: + start = axisBounds.topLeft; + end = axisBounds.bottomLeft; + break; + case AxisOrientation.left: + start = axisBounds.topRight; + end = axisBounds.bottomRight; + break; + } + + canvas.drawLine( + points: [start, end], + fill: axisLineStyle.color, + stroke: axisLineStyle.color, + strokeWidthPx: axisLineStyle.strokeWidth.toDouble(), + ); + } + + @protected + void drawLabel(ChartCanvas canvas, Tick tick, + {@required AxisOrientation orientation, + @required Rectangle axisBounds, + @required Rectangle drawAreaBounds}) { + final locationPx = tick.locationPx; + final measurement = tick.textElement.measurement; + final isRTL = chartContext.rtl && + chartContext.rtlSpec.axisPosition == AxisPosition.reversed; + // TODO: Get its first tick and its last tick values. + final isFirst = false; + final isLast = false; + + int x = 0; + int y = 0; + + if (orientation == AxisOrientation.bottom || + orientation == AxisOrientation.top) { + y = orientation == AxisOrientation.bottom + ? axisBounds.top + labelOffsetFromAxisPx + : axisBounds.bottom - + measurement.verticalSliceWidth.toInt() - + labelOffsetFromAxisPx; + + final direction = + _normalizeHorizontalAnchor(tickLabelAnchor, isRTL, isFirst, isLast); + tick.textElement.textDirection = direction; + + switch (direction) { + case TextDirection.rtl: + x = (locationPx + labelOffsetFromTickPx).toInt(); + break; + case TextDirection.ltr: + x = (locationPx - labelOffsetFromTickPx).toInt(); + break; + case TextDirection.center: + default: + x = locationPx.toInt(); + break; + } + } else { + if (orientation == AxisOrientation.left) { + if (tickLabelJustification == TickLabelJustification.inside) { + x = axisBounds.right - labelOffsetFromAxisPx; + tick.textElement.textDirection = TextDirection.rtl; + } else { + x = axisBounds.left + labelOffsetFromAxisPx; + tick.textElement.textDirection = TextDirection.ltr; + } + } else { + // orientation == right + if (tickLabelJustification == TickLabelJustification.inside) { + x = axisBounds.left + labelOffsetFromAxisPx; + tick.textElement.textDirection = TextDirection.ltr; + } else { + x = axisBounds.right - labelOffsetFromAxisPx; + tick.textElement.textDirection = TextDirection.rtl; + } + } + + switch (_normalizeVerticalAnchor(tickLabelAnchor, isFirst, isLast)) { + case _PixelVerticalDirection.over: + y = (locationPx - + measurement.verticalSliceWidth - + labelOffsetFromTickPx) + .toInt(); + break; + case _PixelVerticalDirection.under: + y = (locationPx + labelOffsetFromTickPx).toInt(); + break; + case _PixelVerticalDirection.center: + default: + y = (locationPx - measurement.verticalSliceWidth / 2).toInt(); + break; + } + } + + canvas.drawText(tick.textElement, x, y); + } + + TextDirection _normalizeHorizontalAnchor( + TickLabelAnchor anchor, bool isRTL, bool isFirst, bool isLast) { + switch (anchor) { + case TickLabelAnchor.before: + return isRTL ? TextDirection.ltr : TextDirection.rtl; + case TickLabelAnchor.after: + return isRTL ? TextDirection.rtl : TextDirection.ltr; + case TickLabelAnchor.inside: + if (isFirst) { + return isRTL ? TextDirection.rtl : TextDirection.ltr; + } + if (isLast) { + return isRTL ? TextDirection.ltr : TextDirection.rtl; + } + return TextDirection.center; + case TickLabelAnchor.centered: + default: + return TextDirection.center; + } + } + + _PixelVerticalDirection _normalizeVerticalAnchor( + TickLabelAnchor anchor, bool isFirst, bool isLast) { + switch (anchor) { + case TickLabelAnchor.before: + return _PixelVerticalDirection.under; + case TickLabelAnchor.after: + return _PixelVerticalDirection.over; + case TickLabelAnchor.inside: + if (isFirst) { + return _PixelVerticalDirection.over; + } + if (isLast) { + return _PixelVerticalDirection.under; + } + return _PixelVerticalDirection.center; + case TickLabelAnchor.centered: + default: + return _PixelVerticalDirection.center; + } + } +} + +enum _PixelVerticalDirection { + over, + center, + under, +} diff --git a/charts_common/lib/src/chart/cartesian/axis/draw_strategy/gridline_draw_strategy.dart b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/gridline_draw_strategy.dart new file mode 100644 index 000000000..c03b1516d --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/gridline_draw_strategy.dart @@ -0,0 +1,167 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable, required; +import 'dart:math'; + +import '../../../../common/style/style_factory.dart' show StyleFactory; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../../common/line_style.dart' show LineStyle; +import '../../../common/chart_canvas.dart' show ChartCanvas; +import '../../../common/chart_context.dart' show ChartContext; +import '../axis.dart' show AxisOrientation; +import '../spec/axis_spec.dart' + show TextStyleSpec, LineStyleSpec, TickLabelAnchor, TickLabelJustification; +import 'small_tick_draw_strategy.dart' show SmallTickRendererSpec; +import '../tick.dart' show Tick; +import 'base_tick_draw_strategy.dart' show BaseTickDrawStrategy; +import 'tick_draw_strategy.dart' show TickDrawStrategy; + +@immutable +class GridlineRendererSpec extends SmallTickRendererSpec { + GridlineRendererSpec({ + TextStyleSpec labelStyle, + LineStyleSpec lineStyle, + LineStyleSpec axisLineStyle, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int tickLengthPx, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + int minimumPaddingBetweenLabelsPx, + }) : super( + labelStyle: labelStyle, + lineStyle: lineStyle, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx, + tickLengthPx: tickLengthPx, + axisLineStyle: axisLineStyle); + + @override + TickDrawStrategy createDrawStrategy( + ChartContext chartContext, GraphicsFactory graphicsFactory) => + new GridlineTickDrawStrategy(chartContext, graphicsFactory, + tickLengthPx: tickLengthPx, + lineStyleSpec: lineStyle, + labelStyleSpec: labelStyle, + axisLineStyleSpec: axisLineStyle, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx); + + @override + bool operator ==(Object other) { + return other is GridlineRendererSpec && super == (other); + } + + @override + int get hashCode { + int hashcode = super.hashCode; + return hashcode; + } +} + +/// Draws line across chart draw area for each tick. +/// +/// Extends [BaseTickDrawStrategy]. +class GridlineTickDrawStrategy extends BaseTickDrawStrategy { + int tickLength; + LineStyle lineStyle; + + GridlineTickDrawStrategy( + ChartContext chartContext, + GraphicsFactory graphicsFactory, { + int tickLengthPx, + LineStyleSpec lineStyleSpec, + TextStyleSpec labelStyleSpec, + LineStyleSpec axisLineStyleSpec, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + int minimumPaddingBetweenLabelsPx, + }) : super(chartContext, graphicsFactory, + labelStyleSpec: labelStyleSpec, + axisLineStyleSpec: axisLineStyleSpec ?? lineStyleSpec, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx) { + lineStyle = + StyleFactory.style.createGridlineStyle(graphicsFactory, lineStyleSpec); + + this.tickLength = tickLengthPx ?? 0; + } + + @override + void draw(ChartCanvas canvas, Tick tick, + {@required AxisOrientation orientation, + @required Rectangle axisBounds, + @required Rectangle drawAreaBounds}) { + Point lineStart; + Point lineEnd; + switch (orientation) { + case AxisOrientation.top: + final x = tick.locationPx; + lineStart = new Point(x, axisBounds.bottom - tickLength); + lineEnd = new Point(x, drawAreaBounds.bottom); + break; + case AxisOrientation.bottom: + final x = tick.locationPx; + lineStart = new Point(x, drawAreaBounds.top + tickLength); + lineEnd = new Point(x, axisBounds.top); + break; + case AxisOrientation.right: + final y = tick.locationPx; + if (tickLabelAnchor == TickLabelAnchor.after || + tickLabelAnchor == TickLabelAnchor.before) { + lineStart = new Point(axisBounds.right, y); + } else { + lineStart = new Point(axisBounds.left + tickLength, y); + } + lineEnd = new Point(drawAreaBounds.left, y); + break; + case AxisOrientation.left: + final y = tick.locationPx; + + if (tickLabelAnchor == TickLabelAnchor.after || + tickLabelAnchor == TickLabelAnchor.before) { + lineStart = new Point(axisBounds.left, y); + } else { + lineStart = new Point(axisBounds.right - tickLength, y); + } + lineEnd = new Point(drawAreaBounds.right, y); + break; + } + + canvas.drawLine( + points: [lineStart, lineEnd], + fill: lineStyle.color, + stroke: lineStyle.color, + strokeWidthPx: lineStyle.strokeWidth.toDouble(), + ); + + drawLabel(canvas, tick, + orientation: orientation, + axisBounds: axisBounds, + drawAreaBounds: drawAreaBounds); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/draw_strategy/none_draw_strategy.dart b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/none_draw_strategy.dart new file mode 100644 index 000000000..421529b31 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/none_draw_strategy.dart @@ -0,0 +1,121 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:meta/meta.dart' show immutable, required; + +import '../axis.dart' show AxisOrientation; +import '../collision_report.dart' show CollisionReport; +import '../spec/axis_spec.dart' show RenderSpec, LineStyleSpec; +import '../tick.dart' show Tick; +import '../../../common/chart_canvas.dart' show ChartCanvas; +import '../../../common/chart_context.dart' show ChartContext; +import '../../../layout/layout_view.dart' show ViewMeasuredSizes; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../../common/line_style.dart' show LineStyle; +import '../../../../common/style/style_factory.dart' show StyleFactory; + +import 'tick_draw_strategy.dart'; + +/// Renders no ticks no labels, and claims no space in layout. +/// However, it does render the axis line if asked to by the axis. +@immutable +class NoneRenderSpec extends RenderSpec { + final LineStyleSpec axisLineStyle; + + NoneRenderSpec({this.axisLineStyle}); + + @override + TickDrawStrategy createDrawStrategy( + ChartContext chartContext, GraphicsFactory graphicsFactory) => + new NoneDrawStrategy(chartContext, graphicsFactory, + axisLineStyleSpec: axisLineStyle); + + @override + bool operator ==(Object other) => other is NoneRenderSpec; + + @override + int get hashCode => 0; +} + +class NoneDrawStrategy implements TickDrawStrategy { + LineStyle axisLineStyle; + + NoneDrawStrategy(ChartContext chartContext, GraphicsFactory graphicsFactory, + {LineStyleSpec axisLineStyleSpec}) { + axisLineStyle = StyleFactory.style + .createAxisLineStyle(graphicsFactory, axisLineStyleSpec); + } + + @override + CollisionReport collides(List ticks, AxisOrientation orientation) => + new CollisionReport(ticksCollide: false, ticks: ticks); + + @override + void decorateTicks(List ticks) {} + + @override + void drawAxisLine(ChartCanvas canvas, AxisOrientation orientation, + Rectangle axisBounds) { + Point start; + Point end; + + switch (orientation) { + case AxisOrientation.top: + start = axisBounds.bottomLeft; + end = axisBounds.bottomRight; + + break; + case AxisOrientation.bottom: + start = axisBounds.topLeft; + end = axisBounds.topRight; + break; + case AxisOrientation.right: + start = axisBounds.topLeft; + end = axisBounds.bottomLeft; + break; + case AxisOrientation.left: + start = axisBounds.topRight; + end = axisBounds.bottomRight; + break; + } + + canvas.drawLine( + points: [start, end], + fill: axisLineStyle.color, + stroke: axisLineStyle.color, + strokeWidthPx: axisLineStyle.strokeWidth.toDouble(), + ); + } + + @override + void draw(ChartCanvas canvas, Tick tick, + {@required AxisOrientation orientation, + @required Rectangle axisBounds, + @required Rectangle drawAreaBounds}) {} + + @override + ViewMeasuredSizes measureHorizontallyDrawnTicks( + List ticks, int maxWidth, int maxHeight) { + return new ViewMeasuredSizes(preferredWidth: 0, preferredHeight: 0); + } + + @override + ViewMeasuredSizes measureVerticallyDrawnTicks( + List ticks, int maxWidth, int maxHeight) { + return new ViewMeasuredSizes(preferredWidth: 0, preferredHeight: 0); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/draw_strategy/small_tick_draw_strategy.dart b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/small_tick_draw_strategy.dart new file mode 100644 index 000000000..711eee056 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/small_tick_draw_strategy.dart @@ -0,0 +1,161 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable, required; +import 'dart:math'; + +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../../common/line_style.dart' show LineStyle; +import '../../../../common/style/style_factory.dart' show StyleFactory; +import '../../../common/chart_canvas.dart' show ChartCanvas; +import '../../../common/chart_context.dart' show ChartContext; +import '../axis.dart' show AxisOrientation; +import '../spec/axis_spec.dart' + show TextStyleSpec, LineStyleSpec, TickLabelAnchor, TickLabelJustification; +import '../tick.dart' show Tick; +import 'base_tick_draw_strategy.dart' show BaseRenderSpec, BaseTickDrawStrategy; +import 'tick_draw_strategy.dart' show TickDrawStrategy; + +/// +@immutable +class SmallTickRendererSpec extends BaseRenderSpec { + final LineStyleSpec lineStyle; + final int tickLengthPx; + + SmallTickRendererSpec({ + TextStyleSpec labelStyle, + this.lineStyle, + LineStyleSpec axisLineStyle, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + this.tickLengthPx, + int minimumPaddingBetweenLabelsPx, + }) : super( + labelStyle: labelStyle, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx, + axisLineStyle: axisLineStyle); + + @override + TickDrawStrategy createDrawStrategy( + ChartContext chartContext, GraphicsFactory graphicsFactory) => + new SmallTickDrawStrategy(chartContext, graphicsFactory, + tickLengthPx: tickLengthPx, + lineStyleSpec: lineStyle, + labelStyleSpec: labelStyle, + axisLineStyleSpec: axisLineStyle, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx); + + @override + bool operator ==(Object other) { + return other is SmallTickRendererSpec && + lineStyle == other.lineStyle && + tickLengthPx == other.tickLengthPx && + super == (other); + } + + @override + int get hashCode { + int hashcode = lineStyle?.hashCode ?? 0; + hashcode = (hashcode * 37) + tickLengthPx?.hashCode ?? 0; + hashcode = (hashcode * 37) + super.hashCode; + return hashcode; + } +} + +/// Draws small tick lines for each tick. Extends [BaseTickDrawStrategy]. +class SmallTickDrawStrategy extends BaseTickDrawStrategy { + int tickLength; + LineStyle lineStyle; + + SmallTickDrawStrategy( + ChartContext chartContext, + GraphicsFactory graphicsFactory, { + int tickLengthPx, + LineStyleSpec lineStyleSpec, + TextStyleSpec labelStyleSpec, + LineStyleSpec axisLineStyleSpec, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + int minimumPaddingBetweenLabelsPx, + }) : super(chartContext, graphicsFactory, + labelStyleSpec: labelStyleSpec, + axisLineStyleSpec: axisLineStyleSpec ?? lineStyleSpec, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx) { + this.tickLength = tickLengthPx ?? StyleFactory.style.tickLength; + lineStyle = + StyleFactory.style.createTickLineStyle(graphicsFactory, lineStyleSpec); + } + + @override + void draw(ChartCanvas canvas, Tick tick, + {@required AxisOrientation orientation, + @required Rectangle axisBounds, + @required Rectangle drawAreaBounds}) { + Point tickStart; + Point tickEnd; + switch (orientation) { + case AxisOrientation.top: + double x = tick.locationPx; + tickStart = new Point(x, axisBounds.bottom - tickLength); + tickEnd = new Point(x, axisBounds.bottom); + break; + case AxisOrientation.bottom: + double x = tick.locationPx; + tickStart = new Point(x, axisBounds.top); + tickEnd = new Point(x, axisBounds.top + tickLength); + break; + case AxisOrientation.right: + double y = tick.locationPx; + + tickStart = new Point(axisBounds.left, y); + tickEnd = new Point(axisBounds.left + tickLength, y); + break; + case AxisOrientation.left: + double y = tick.locationPx; + + tickStart = new Point(axisBounds.right - tickLength, y); + tickEnd = new Point(axisBounds.right, y); + break; + } + + canvas.drawLine( + points: [tickStart, tickEnd], + fill: lineStyle.color, + stroke: lineStyle.color, + strokeWidthPx: lineStyle.strokeWidth.toDouble(), + ); + + drawLabel(canvas, tick, + orientation: orientation, + axisBounds: axisBounds, + drawAreaBounds: drawAreaBounds); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/draw_strategy/tick_draw_strategy.dart b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/tick_draw_strategy.dart new file mode 100644 index 000000000..d496454eb --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/draw_strategy/tick_draw_strategy.dart @@ -0,0 +1,56 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import 'dart:math'; + +import '../../../common/chart_canvas.dart' show ChartCanvas; +import '../../../layout/layout_view.dart' show ViewMeasuredSizes; +import '../axis.dart' show AxisOrientation; +import '../collision_report.dart' show CollisionReport; +import '../tick.dart' show Tick; + +/// Strategy for drawing ticks and checking for collisions. +abstract class TickDrawStrategy { + /// Decorate the existing list of ticks. + /// + /// This can be used to further modify ticks after they have been generated + /// with location data and formatted labels. + void decorateTicks(List> ticks); + + /// Returns a [CollisionReport] indicating if there are any collisions. + CollisionReport collides(List> ticks, AxisOrientation orientation); + + /// Returns measurement of ticks drawn vertically. + ViewMeasuredSizes measureVerticallyDrawnTicks( + List> ticks, int maxWidth, int maxHeight); + + /// Returns measurement of ticks drawn horizontally. + ViewMeasuredSizes measureHorizontallyDrawnTicks( + List> ticks, int maxWidth, int maxHeight); + + /// Draws tick onto [ChartCanvas]. + /// + /// [orientation] the orientation of the axis that this [tick] belongs to. + /// [axisBounds] the bounds of the axis. + /// [drawAreaBounds] the bounds of the chart draw area adjacent to the axis. + void draw(ChartCanvas canvas, Tick tick, + {@required AxisOrientation orientation, + @required Rectangle axisBounds, + @required Rectangle drawAreaBounds}); + + void drawAxisLine(ChartCanvas canvas, AxisOrientation orientation, + Rectangle axisBounds); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale.dart b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale.dart new file mode 100644 index 000000000..648afd7a0 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale.dart @@ -0,0 +1,243 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../numeric_extents.dart' show NumericExtents; +import '../numeric_scale.dart' show NumericScale; +import '../scale.dart' show RangeBandConfig, ScaleOutputExtent, StepSizeConfig; +import 'linear_scale_domain_info.dart' show LinearScaleDomainInfo; +import 'linear_scale_function.dart' show LinearScaleFunction; +import 'linear_scale_viewport.dart' show LinearScaleViewportSettings; + +/// [NumericScale] that lays out the domain linearly across the range. +/// +/// A [Scale] which converts numeric domain units to a given numeric range units +/// linearly (as opposed to other methods like log scales). This is used to map +/// the domain's values to the available pixel range of the chart using the +/// apply method. +/// +///

The domain extent of the scale are determined by adding all domain +/// values to the scale. It can, however, be overwritten by calling +/// [domainOverride] to define the extent of the data. +/// +///

The scale can be zoomed & panned by calling either [setViewportSettings] +/// with a zoom and translate, or by setting [viewportExtent] with the domain +/// extent to show in the output range. +/// +///

[rangeBandConfig]: By default, this scale will map the domain extent +/// exactly to the output range in a simple ratio mapping. If a +/// [RangeBandConfig] other than NONE is used to define the width of bar groups, +/// then the scale calculation may be altered to that there is a half a stepSize +/// at the start and end of the range to ensure that a bar group can be shown +/// and centered on the scale's result. +/// +///

[stepSizeConfig]: By default, this scale will calculate the stepSize as +/// being auto detected using the minimal distance between two consecutive +/// datum. If you don't assign a [RangeBandConfig], then changing the +/// [stepSizeConfig] is a no-op. +class LinearScale implements NumericScale { + final LinearScaleDomainInfo _domainInfo; + final LinearScaleViewportSettings _viewportSettings; + final LinearScaleFunction _scaleFunction = new LinearScaleFunction(); + + RangeBandConfig rangeBandConfig = const RangeBandConfig.none(); + StepSizeConfig stepSizeConfig = const StepSizeConfig.auto(); + + bool _scaleReady = false; + + LinearScale() + : _domainInfo = new LinearScaleDomainInfo(), + _viewportSettings = new LinearScaleViewportSettings(); + + LinearScale._copy(LinearScale other) + : _domainInfo = new LinearScaleDomainInfo.copy(other._domainInfo), + _viewportSettings = + new LinearScaleViewportSettings.copy(other._viewportSettings), + rangeBandConfig = other.rangeBandConfig, + stepSizeConfig = other.stepSizeConfig; + + @override + LinearScale copy() => new LinearScale._copy(this); + + // + // Domain methods + // + + @override + addDomain(num domainValue) { + _domainInfo.addDomainValue(domainValue); + } + + @override + resetDomain() { + _scaleReady = false; + _domainInfo.reset(); + } + + @override + resetViewportSettings() { + _viewportSettings.reset(); + } + + @override + NumericExtents get dataExtent => new NumericExtents( + _domainInfo.dataDomainStart, _domainInfo.dataDomainEnd); + + @override + num get minimumDomainStep => _domainInfo.minimumDetectedDomainStep; + + @override + bool canTranslate(_) => true; + + @override + set domainOverride(NumericExtents domainMaxExtent) { + _domainInfo.domainOverride = domainMaxExtent; + } + + get domainOverride => _domainInfo.domainOverride; + + @override + int compareDomainValueToViewport(num domainValue) { + NumericExtents dataExtent = _viewportSettings.domainExtent != null + ? _viewportSettings.domainExtent + : _domainInfo.extent; + return dataExtent.compareValue(domainValue); + } + + // + // Viewport methods + // + + @override + setViewportSettings(double viewportScale, double viewportTranslatePx) { + _viewportSettings + ..scalingFactor = viewportScale + ..translatePx = viewportTranslatePx + ..domainExtent = null; + _scaleReady = false; + } + + @override + double get viewportScalingFactor => _viewportSettings.scalingFactor; + + @override + double get viewportTranslatePx => _viewportSettings.translatePx; + + @override + set viewportDomain(NumericExtents extent) { + _scaleReady = false; + _viewportSettings.domainExtent = extent; + } + + @override + NumericExtents get viewportDomain { + _configureScale(); + return _viewportSettings.domainExtent; + } + + @override + set keepViewportWithinData(bool autoAdjustViewportToNiceValues) { + _scaleReady = false; + _viewportSettings.keepViewportWithinData = true; + } + + @override + bool get keepViewportWithinData => _viewportSettings.keepViewportWithinData; + + @override + double computeViewportScaleFactor(double domainWindow) => + _domainInfo.domainDiff / domainWindow; + + @override + set range(ScaleOutputExtent extent) { + _viewportSettings.range = extent; + _scaleReady = false; + } + + @override + ScaleOutputExtent get range => _viewportSettings.range; + + // + // Scale application methods + // + + @override + num operator [](num domainValue) { + _configureScale(); + return _scaleFunction[domainValue]; + } + + @override + num reverse(double viewPixels) { + _configureScale(); + final num domain = _scaleFunction.reverse(viewPixels); + return domain; + } + + @override + double get rangeBand { + _configureScale(); + return _scaleFunction.rangeBandPixels; + } + + @override + double get stepSize { + _configureScale(); + return _scaleFunction.stepSizePixels; + } + + @override + int get rangeWidth => (range.end - range.start).abs().toInt(); + + @override + bool isRangeValueWithinViewport(double rangeValue) => + range.containsValue(rangeValue); + + // + // Private update + // + + _configureScale() { + if (_scaleReady) return; + + assert(_viewportSettings.range != null); + + // If the viewport's domainExtent are set, then we can calculate the + // viewport's scaleFactor now that the domainInfo has been loaded. + // The viewport also has a chance to correct the scaleFactor. + _viewportSettings.updateViewportScaleFactor(_domainInfo); + // Now that the viewport's scalingFactor is setup, set it on the scale + // function. + _scaleFunction.updateScaleFactor( + _viewportSettings, _domainInfo, rangeBandConfig, stepSizeConfig); + + // If the viewport's domainExtent are set, then we can calculate the + // viewport's translate now that the scaleFactor has been loaded. + // The viewport also has a chance to correct the translate. + _viewportSettings.updateViewportTranslatePx( + _domainInfo, _scaleFunction.scalingFactor); + // Now that the viewport has a chance to update the translate, set it on the + // scale function. + _scaleFunction.updateTranslateAndRangeBand( + _viewportSettings, _domainInfo, rangeBandConfig); + + // Now that the viewport's scaleFactor and translate have been updated + // set the effective domainExtent of the viewport. + _viewportSettings.updateViewportDomainExtent( + _domainInfo, _scaleFunction.scalingFactor); + + // Cached computed values are updated. + _scaleReady = true; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_domain_info.dart b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_domain_info.dart new file mode 100644 index 000000000..f04c3a22d --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_domain_info.dart @@ -0,0 +1,118 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../numeric_extents.dart' show NumericExtents; + +/// Encapsulation of all the domain processing logic for the [LinearScale]. +class LinearScaleDomainInfo { + /// User (or axis) overridden extent in domain units. + NumericExtents domainOverride; + + /// The minimum added domain value. + num _dataDomainStart = double.INFINITY; + num get dataDomainStart => _dataDomainStart; + + /// The maximum added domain value. + num _dataDomainEnd = double.NEGATIVE_INFINITY; + num get dataDomainEnd => _dataDomainEnd; + + /// Previous domain added so we can calculate minimumDetectedDomainStep. + num _previouslyAddedDomain; + + /// The step size between data points in domain units. + /// + /// Measured as the minimum distance between consecutive added points. + num _minimumDetectedDomainStep = double.INFINITY; + num get minimumDetectedDomainStep => _minimumDetectedDomainStep; + + ///The diff of the nicedDomain extent. + num get domainDiff => extent.width; + + LinearScaleDomainInfo(); + + LinearScaleDomainInfo.copy(LinearScaleDomainInfo other) { + if (other.domainOverride != null) { + domainOverride = other.domainOverride; + } + _dataDomainStart = other._dataDomainStart; + _dataDomainEnd = other._dataDomainEnd; + _previouslyAddedDomain = other._previouslyAddedDomain; + _minimumDetectedDomainStep = other._minimumDetectedDomainStep; + } + + /// Resets everything back to initial state. + void reset() { + _previouslyAddedDomain = null; + _dataDomainStart = double.INFINITY; + _dataDomainEnd = double.NEGATIVE_INFINITY; + _minimumDetectedDomainStep = double.INFINITY; + } + + /// Updates the domain extent and detected step size given the [domainValue]. + void addDomainValue(num domainValue) { + if (domainValue == null || !domainValue.isFinite) { + return; + } + + extendDomain(domainValue); + + if (_previouslyAddedDomain != null) { + final domainStep = (domainValue - _previouslyAddedDomain).abs(); + if (domainStep != 0.0 && domainStep < minimumDetectedDomainStep) { + _minimumDetectedDomainStep = domainStep; + } + } + _previouslyAddedDomain = domainValue; + } + + /// Extends the data domain extent without modifying step size detection. + /// + /// Returns whether the the domain interval was extended. If the domain value + /// was already contained in the domain interval, the domain interval does not + /// change. + bool extendDomain(num domainValue) { + if (domainValue == null || !domainValue.isFinite) { + return false; + } + + bool domainExtended = false; + if (domainValue < _dataDomainStart) { + _dataDomainStart = domainValue; + domainExtended = true; + } + if (domainValue > _dataDomainEnd) { + _dataDomainEnd = domainValue; + domainExtended = true; + } + return domainExtended; + } + + /// Returns the extent based on the current domain range and overrides. + NumericExtents get extent { + var tmpDomainStart; + var tmpDomainEnd; + if (domainOverride != null) { + // override was set. + tmpDomainStart = domainOverride.min; + tmpDomainEnd = domainOverride.max; + } else { + // domainEnd is less than domainStart if no domain values have been set. + tmpDomainStart = _dataDomainStart.isFinite ? _dataDomainStart : 0.0; + tmpDomainEnd = _dataDomainEnd.isFinite ? _dataDomainEnd : 1.0; + } + + return new NumericExtents(tmpDomainStart, tmpDomainEnd); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_function.dart b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_function.dart new file mode 100644 index 000000000..8b1ab9d61 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_function.dart @@ -0,0 +1,201 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../scale.dart' + show RangeBandConfig, RangeBandType, StepSizeConfig, StepSizeType; +import 'linear_scale_domain_info.dart' show LinearScaleDomainInfo; +import 'linear_scale_viewport.dart' show LinearScaleViewportSettings; + +/// Component of the LinearScale which actually handles the apply and reverse +/// function of the scale. +class LinearScaleFunction { + /// Cached rangeBand width in pixels given the RangeBandConfig and the current + /// domain & range. + double rangeBandPixels = 0.0; + + /// Cached amount in domain units to shift the input value as a part of + /// translation. + num domainTranslate = 0.0; + + /// Cached translation ratio for scale translation. + double scalingFactor = 1.0; + + /// Cached amount in pixel units to shift the output value as a part of + /// translation. + double rangeTranslate = 0.0; + + /// The calculated step size given the step size config. + double stepSizePixels = 0.0; + + /// Translates the given domainValue to the range output. + double operator [](num domainValue) { + return (((domainValue + domainTranslate) * scalingFactor) + rangeTranslate) + .toDouble(); + } + + /// Translates the given range output back to a domainValue. + double reverse(double viewPixels) { + return ((viewPixels - rangeTranslate) / scalingFactor) - domainTranslate; + } + + /// Update the scale function's scaleFactor given the current state of the + /// viewport. + void updateScaleFactor( + LinearScaleViewportSettings viewportSettings, + LinearScaleDomainInfo domainInfo, + RangeBandConfig rangeBandConfig, + StepSizeConfig stepSizeConfig) { + double rangeDiff = viewportSettings.range.diff.toDouble(); + // Note: if you provided a nicing function that extends the domain, we won't + // muck with the extended side. + bool hasHalfStepAtStart = + domainInfo.extent.min == domainInfo.dataDomainStart; + bool hasHalfStepAtEnd = domainInfo.extent.max == domainInfo.dataDomainEnd; + + // Determine the stepSize and reserved range values. + // The percentage of the step reserved from the scale's range due to the + // possible half step at the start and end. + double reservedRangePercentOfStep = + getStepReservationPercent(hasHalfStepAtStart, hasHalfStepAtEnd); + _updateStepSizeAndScaleFactor(viewportSettings, domainInfo, rangeDiff, + reservedRangePercentOfStep, rangeBandConfig, stepSizeConfig); + } + + /// Returns the percentage of the step reserved from the output range due to + /// maybe having to hold half stepSizes on the start and end of the output. + double getStepReservationPercent( + bool hasHalfStepAtStart, bool hasHalfStepAtEnd) { + if (!hasHalfStepAtStart && !hasHalfStepAtEnd) { + return 0.0; + } + if (hasHalfStepAtStart && hasHalfStepAtEnd) { + return 1.0; + } + return 0.5; + } + + /// Updates the scale function's translate and rangeBand given the current + /// state of the viewport. + void updateTranslateAndRangeBand(LinearScaleViewportSettings viewportSettings, + LinearScaleDomainInfo domainInfo, RangeBandConfig rangeBandConfig) { + // Assign the rangeTranslate using the current viewportSettings.translatePx + // and diffs. + if (domainInfo.domainDiff == 0) { + // Translate it to the center of the range. + rangeTranslate = + viewportSettings.range.start + (viewportSettings.range.diff / 2); + } else { + bool hasHalfStepAtStart = + domainInfo.extent.min == domainInfo.dataDomainStart; + // The pixel shift of the scale function due to the half a step at the + // beginning. + double reservedRangePixelShift = + hasHalfStepAtStart ? (stepSizePixels / 2.0) : 0.0; + + rangeTranslate = (viewportSettings.range.start + + viewportSettings.translatePx + + reservedRangePixelShift); + } + + // We need to subtract the start from any incoming domain to apply the + // scale, so flip its sign. + domainTranslate = -1 * domainInfo.extent.min; + + // Update the rangeBand size. + rangeBandPixels = _calculateRangeBandSize(rangeBandConfig); + } + + /// Calculates and stores the current rangeBand given the config and current + /// step size. + double _calculateRangeBandSize(RangeBandConfig rangeBandConfig) { + switch (rangeBandConfig.type) { + case RangeBandType.fixedDomain: + return rangeBandConfig.size * scalingFactor; + case RangeBandType.fixedPixel: + return rangeBandConfig.size; + case RangeBandType.fixedPixelSpaceFromStep: + return stepSizePixels - rangeBandConfig.size; + case RangeBandType.styleAssignedPercentOfStep: + case RangeBandType.fixedPercentOfStep: + return stepSizePixels * rangeBandConfig.size; + case RangeBandType.none: + return 0.0; + } + return 0.0; + } + + /// Calculates and Stores the current step size and scale factor together, + /// given the viewport, domain, and config. + /// + ///

Scale factor and step size are related closely and should be calculated + /// together so that we do not lose accuracy due to double arithmetic. + void _updateStepSizeAndScaleFactor( + LinearScaleViewportSettings viewportSettings, + LinearScaleDomainInfo domainInfo, + double rangeDiff, + double reservedRangePercentOfStep, + RangeBandConfig rangeBandConfig, + StepSizeConfig stepSizeConfig) { + final domainDiff = domainInfo.domainDiff; + + // If we are going to have any rangeBands, then ensure that we account for + // needed space on the beginning and end of the range. + if (rangeBandConfig.type != RangeBandType.none) { + switch (stepSizeConfig.type) { + case StepSizeType.autoDetect: + double minimumDetectedDomainStep = + domainInfo.minimumDetectedDomainStep; + if (minimumDetectedDomainStep != null && + minimumDetectedDomainStep.isFinite) { + scalingFactor = viewportSettings.scalingFactor * + (rangeDiff / + (domainDiff + + (minimumDetectedDomainStep * + reservedRangePercentOfStep))); + stepSizePixels = (minimumDetectedDomainStep * scalingFactor); + } else { + stepSizePixels = rangeDiff.abs(); + scalingFactor = 1.0; + } + return; + case StepSizeType.fixedPixels: + stepSizePixels = stepSizeConfig.size; + double reservedRangeForStepPixels = + stepSizePixels * reservedRangePercentOfStep; + scalingFactor = domainDiff == 0 + ? 1.0 + : viewportSettings.scalingFactor * + (rangeDiff - reservedRangeForStepPixels) / + domainDiff; + return; + case StepSizeType.fixedDomain: + double domainStepWidth = stepSizeConfig.size; + double totalDomainDiff = + (domainDiff + (domainStepWidth * reservedRangePercentOfStep)); + scalingFactor = totalDomainDiff == 0 + ? 1.0 + : viewportSettings.scalingFactor * (rangeDiff / totalDomainDiff); + stepSizePixels = domainStepWidth * scalingFactor; + return; + } + } + + // If no cases matched, use zero step size. + stepSizePixels = 0.0; + scalingFactor = domainDiff == 0 + ? 1.0 + : viewportSettings.scalingFactor * rangeDiff / domainDiff; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_viewport.dart b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_viewport.dart new file mode 100644 index 000000000..a3653f33e --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/linear/linear_scale_viewport.dart @@ -0,0 +1,141 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' as math show max, min; + +import '../numeric_extents.dart' show NumericExtents; +import '../scale.dart' show ScaleOutputExtent; +import 'linear_scale_domain_info.dart' show LinearScaleDomainInfo; + +/// Component of the LinearScale responsible for the configuration and +/// calculations of the viewport. +class LinearScaleViewportSettings { + /// Output extent for the scale, typically set by the axis as the pixel + /// output. + ScaleOutputExtent range; + + /// Determines whether the scale should be extended to the nice values + /// provided by the tick provider. If true, we wont touch the viewport config + /// since the axis will configure it, if false, we will still ensure sane zoom + /// and translates. + bool keepViewportWithinData = true; + + /// User configured viewport scale as a zoom multiplier where 1.0 is + /// 100% (default) and 2.0 is 200% zooming in making the data take up twice + /// the space (showing half as much data in the viewport). + double scalingFactor = 1.0; + + /// User configured viewport translate in pixel units. + double translatePx = 0.0; + + /// The current extent of the viewport in domain units. + NumericExtents _domainExtent; + set domainExtent(NumericExtents extent) { + _domainExtent = extent; + _manualDomainExtent = extent != null; + } + + NumericExtents get domainExtent => _domainExtent; + + /// Indicates that the viewportExtends are to be read from to determine the + /// internal scaleFactor and rangeTranslate. + + bool _manualDomainExtent = false; + + LinearScaleViewportSettings(); + + LinearScaleViewportSettings.copy(LinearScaleViewportSettings other) { + range = other.range; + keepViewportWithinData = other.keepViewportWithinData; + scalingFactor = other.scalingFactor; + translatePx = other.translatePx; + _manualDomainExtent = other._manualDomainExtent; + _domainExtent = other._domainExtent; + } + + /// Resets the viewport calculated fields back to their initial settings. + void reset() { + // Likely an auto assigned viewport (niced), so reset it between draws. + scalingFactor = 1.0; + translatePx = 0.0; + domainExtent = null; + } + + int get rangeWidth => range.diff.abs().toInt(); + + bool isRangeValueWithinViewport(double rangeValue) => + range.containsValue(rangeValue); + + /// Updates the viewport's internal scalingFactor given the current + /// domainInfo. + void updateViewportScaleFactor(LinearScaleDomainInfo domainInfo) { + // If we are loading from the viewport, then update the scalingFactor given + // the viewport size compared to the data size. + if (_manualDomainExtent) { + double viewportDomainDiff = _domainExtent?.width; + if (domainInfo.domainDiff != 0.0) { + scalingFactor = domainInfo.domainDiff / viewportDomainDiff; + } else { + scalingFactor = 1.0; + // The domain claims to have no date, extend it to the viewport's + domainInfo.extendDomain(_domainExtent?.min); + domainInfo.extendDomain(_domainExtent?.max); + } + } + + // Make sure that the viewportSettings.scalingFactor is sane if desired. + if (!keepViewportWithinData) { + // Make sure we don't zoom out beyond the max domain extent. + scalingFactor = math.max(1.0, scalingFactor); + } + } + + /// Updates the viewport's internal translate given the current domainInfo and + /// main scalingFactor from LinearScaleFunction (not internal scalingFactor). + void updateViewportTranslatePx( + LinearScaleDomainInfo domainInfo, double scaleScalingFactor) { + // If we are loading from the viewport, then update the translate now that + // the scaleFactor has been setup. + if (_manualDomainExtent) { + translatePx = (-1.0 * + scaleScalingFactor * + (_domainExtent.min - domainInfo.extent.min)); + } + + // Make sure that the viewportSettings.translatePx is sane if desired. + if (!keepViewportWithinData) { + int rangeDiff = range.diff.toInt(); + + // Make sure we don't translate beyond the max domain extent. + translatePx = math.min(0.0, translatePx); + translatePx = math.max(rangeDiff * (1.0 - scalingFactor), translatePx); + } + } + + /// Calculates and stores the viewport's domainExtent if we did not load from + /// them in the first place. + void updateViewportDomainExtent( + LinearScaleDomainInfo domainInfo, double scaleScalingFactor) { + // If we didn't load from the viewport extent, then update them given the + // current scale configuration. + if (!_manualDomainExtent) { + double viewportDomainDiff = domainInfo.domainDiff / scalingFactor; + double viewportStart = + (-1.0 * translatePx / scaleScalingFactor) + domainInfo.extent.min; + _domainExtent = + new NumericExtents(viewportStart, viewportStart + viewportDomainDiff); + } + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/numeric_extents.dart b/charts_common/lib/src/chart/cartesian/axis/numeric_extents.dart new file mode 100644 index 000000000..e72a5059f --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/numeric_extents.dart @@ -0,0 +1,105 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'scale.dart' show Extents; + +/// Represents the starting and ending extent of a dataset. +class NumericExtents implements Extents { + final num min; + final num max; + + /// Precondition: [min] <= [max]. + // TODO: When initializer list asserts are supported everywhere, + // add the precondition as an initializer list assert. This is supported in + // Flutter only. + const NumericExtents(this.min, this.max); + + /// Returns [Extents] based on the min and max of the given values. + /// Returns [NumericExtents.empty] if [values] are empty + factory NumericExtents.fromValues(Iterable values) { + if (values.isEmpty) { + return NumericExtents.empty; + } + var min = values.first; + var max = values.first; + for (final value in values) { + if (value < min) { + min = value; + } else if (max < value) { + max = value; + } + } + return new NumericExtents(min, max); + } + + /// Returns the union of this and other. + NumericExtents plus(NumericExtents other) { + if (min <= other.min) { + if (max >= other.max) { + return this; + } else { + return new NumericExtents(min, other.max); + } + } else { + if (other.max >= max) { + return other; + } else { + return new NumericExtents(other.min, max); + } + } + } + + /// Compares the given [value] against the extents. + /// + /// Returns -1 if the value is less than the extents. + /// Returns 0 if the value is within the extents inclusive. + /// Returns 1 if the value is greater than the extents. + int compareValue(num value) { + if (value < min) { + return -1; + } + if (value > max) { + return 1; + } + return 0; + } + + bool _containsValue(double value) => compareValue(value) == 0; + + // Returns true if these [NumericExtents] collides with [other]. + bool overlaps(NumericExtents other) { + return _containsValue(other.min) || + _containsValue(other.max) || + other._containsValue(min) || + other._containsValue(max); + } + + @override + bool operator ==(other) { + return other is NumericExtents && min == other.min && max == other.max; + } + + @override + int get hashCode => (min.hashCode + (max.hashCode * 31)); + + num get width => max - min; + + @override + String toString() => "Extent($min, $max)"; + + static const NumericExtents unbounded = + const NumericExtents(double.NEGATIVE_INFINITY, double.INFINITY); + static const NumericExtents empty = const NumericExtents(0.0, 0.0); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/numeric_scale.dart b/charts_common/lib/src/chart/cartesian/axis/numeric_scale.dart new file mode 100644 index 000000000..f7de2decc --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/numeric_scale.dart @@ -0,0 +1,53 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'numeric_extents.dart' show NumericExtents; +import 'scale.dart' show MutableScale; + +/// Scale used to convert numeric domain input units to output range units. +/// +/// The input represents a continuous numeric domain which maps to a given range +/// output. This is used to map the domain's values to the available pixel +/// range of the chart. +abstract class NumericScale extends MutableScale { + /// Keeps the scale and translate sane if true (default). + /// + /// Setting this to false disables some pan/zoom protections that prevent you + /// from going beyond the data extent. + bool get keepViewportWithinData; + set keepViewportWithinData(bool keep); + + /// Returns the extent of the actual data (not the viewport max). + NumericExtents get dataExtent; + + /// Returns the minimum step size of the actual data. + num get minimumDomainStep; + + /// Overrides the domain extent if set, null otherwise. + /// + /// Overrides the extent of the actual data to lie about the range of the + /// data so that panning has a start and end point to go between beyond the + /// received data. This allows lazy loading of data into the gaps in the + /// expanded lied about areas. + NumericExtents get domainOverride; + set domainOverride(NumericExtents extent); + + /// The domain extent visible in the viewport of the drawArea. + NumericExtents get viewportDomain; + set viewportDomain(NumericExtents extent); + + /// Returns the viewportScaleFactor needed to present the given domainWindow. + double computeViewportScaleFactor(double domainWindow); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/numeric_tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/numeric_tick_provider.dart new file mode 100644 index 000000000..dbd8b5ac3 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/numeric_tick_provider.dart @@ -0,0 +1,514 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show log, LOG10E, max, min, pow; +import 'package:meta/meta.dart' show required; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../common/chart_context.dart' show ChartContext; +import '../../common/unitconverter/unit_converter.dart' show UnitConverter; +import '../../common/unitconverter/identity_converter.dart' + show IdentityConverter; +import 'axis.dart' show AxisOrientation; +import 'numeric_extents.dart' show NumericExtents; +import 'numeric_scale.dart' show NumericScale; +import 'draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import 'tick.dart' show Tick; +import 'tick_formatter.dart' show TickFormatter; +import 'tick_provider.dart' show BaseTickProvider; + +/// Tick provider that allows you to specify how many ticks to present while +/// also choosing tick values that appear "nice" or "rounded" to the user. By +/// default it will try to guess an appropriate number of ticks given the size +/// of the range available, but the min and max tick counts can be set by +/// calling setTickCounts(). +/// +/// You can control whether the axis is bound to zero (default) or follows the +/// data by calling setZeroBound(). +/// +/// This provider will choose "nice" ticks with the following priority order. +/// * Ticks do not collide with each other. +/// * Alternate rendering is not used to avoid collisions. +/// * Provide the least amount of domain range covering all data points (while +/// still selecting "nice" ticks values. +class NumericTickProvider + extends BaseTickProvider { + /// Used to determine the automatic tick count calculation. + static const MIN_DIPS_BETWEEN_TICKS = 25; + + /// Potential steps available to the baseTen value of the data. + static const DEFAULT_STEPS = const [ + 0.01, + 0.02, + 0.025, + 0.03, + 0.04, + 0.05, + 0.06, + 0.07, + 0.08, + 0.09, + 0.1, + 0.2, + 0.25, + 0.3, + 0.4, + 0.5, + 0.6, + 0.7, + 0.8, + 0.9, + 1.0, + 2.0, + 2.50, + 3.0, + 4.0, + 5.0, + 6.0, + 7.0, + 8.0, + 9.0 + ]; + + // Settings + + /// Sets whether the the tick provider should always include a zero tick. + /// + /// If set the data range may be extended to include zero. + /// + /// Note that the zero value in axis units is chosen, which may be different + /// than zero value in data units if a data to axis unit converter is set. + bool zeroBound = true; + + /// If your data can only be in whole numbers, then set this to true. + /// + /// It should prevent the scale from choosing fractional ticks. For example, + /// if you had a office head count, don't generate a tick for 1.5, instead + /// jump to 2. + /// + /// Note that the provider will choose whole number ticks in the axis units, + /// not data units if a data to axis unit converter is set. + bool dataIsInWholeNumbers = true; + + // Desired min and max tick counts are set by [setFixedTickCount] and + // [setTickCount]. These are not guaranteed tick counts. + int _desiredMaxTickCount; + int _desiredMinTickCount; + + /// Allowed steps the tick provider can choose from. + var _allowedSteps = DEFAULT_STEPS; + + /// Convert input data units to the desired units on the axis. + /// If not set no conversion will take place. + /// + /// Combining this with an appropriate [TickFormatter] would result in axis + /// ticks that are in different unit than the actual data units. + UnitConverter dataToAxisUnitConverter = + const IdentityConverter(); + + // Tick calculation state + num _low; + num _high; + int _rangeWidth; + int _minTickCount; + int _maxTickCount; + + // The parameters used in previous tick calculation + num _prevLow; + num _prevHigh; + int _prevRangeWidth; + int _prevMinTickCount; + int _prevMaxTickCount; + bool _prevDataIsInWholeNumbers; + + /// Sets the desired tick count. + /// + /// While the provider will try to satisfy the requirement, it is not + /// guaranteed, such as cases where ticks may overlap or are insufficient. + /// + /// [tickCount] the fixed number of major (labeled) ticks to draw for the axis + /// Passing null will result in falling back on the automatic tick count + /// assignment. + void setFixedTickCount(int tickCount) { + // Don't allow a single tick, it doesn't make sense. so tickCount > 1 + _desiredMinTickCount = + tickCount != null && tickCount > 1 ? tickCount : null; + _desiredMaxTickCount = _desiredMinTickCount; + } + + /// Sets the desired min and max tick count when providing ticks. + /// + /// The values are suggested requirements but are not guaranteed to be the + /// actual tick count in cases where it is not possible. + /// + /// [maxTickCount] The max tick count must be greater than 1. + /// [minTickCount] The min tick count must be greater than 1. + void setTickCount(int maxTickCount, int minTickCount) { + // Don't allow a single tick, it doesn't make sense. so tickCount > 1 + if (maxTickCount != null && maxTickCount > 1) { + _desiredMaxTickCount = maxTickCount; + if (minTickCount != null && + minTickCount > 1 && + minTickCount <= _desiredMaxTickCount) { + _desiredMinTickCount = minTickCount; + } else { + _desiredMinTickCount = 2; + } + } else { + _desiredMaxTickCount = null; + _desiredMinTickCount = null; + } + } + + /// Sets the allowed step sizes this tick provider can choose from. + /// + /// All ticks will be a power of 10 multiple of the given step sizes. + /// + /// Note that if only very few step sizes are allowed the tick range maybe + /// much bigger than the data range. + /// + /// The step sizes setup here apply in axis units, which is different than + /// input units if a data to axis unit converter is set. + /// + /// [steps] allowed step sizes in the [1, 10) range. + set allowedSteps(List steps) { + assert(steps != null && steps.isNotEmpty); + steps.sort(); + + final stepSet = new Set.from(steps); + _allowedSteps = new List(stepSet.length * 3); + int stepIndex = 0; + for (double step in stepSet) { + assert(1.0 <= step && step < 10.0); + _allowedSteps[stepIndex] = _removeRoundingErrors(step / 100); + _allowedSteps[stepSet.length + stepIndex] = + _removeRoundingErrors(step / 10).toDouble(); + _allowedSteps[2 * stepSet.length + stepIndex] = + _removeRoundingErrors(step); + stepIndex++; + } + } + + @override + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required NumericScale scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }) { + List> ticks; + + _rangeWidth = scale.rangeWidth; + _updateTickCounts(); + _updateDomainExtents(scale.viewportDomain); + + if (_hasTickParametersChanged() || ticks == null) { + var selectedTicksRange = double.MAX_FINITE; + var foundPreferredTicks = false; + var viewportDomain = scale.viewportDomain; + final axisUnitsHigh = dataToAxisUnitConverter.convert(_high); + final axisUnitsLow = dataToAxisUnitConverter.convert(_low); + // Only create a copy of the scale if [viewportExtensionEnabled]. + NumericScale mutableScale = + viewportExtensionEnabled ? scale.copy() : null; + + // Walk to available tick count from max to min looking for the first one + // that gives you the least amount of range used. If a non colliding tick + // count is not found use the min tick count to generate ticks. + for (int tickCount = _maxTickCount; + tickCount >= _minTickCount; + tickCount--) { + final stepInfo = + _getStepsForTickCount(tickCount, axisUnitsHigh, axisUnitsLow); + if (stepInfo == null) { + continue; + } + final firstTick = dataToAxisUnitConverter.invert(stepInfo.tickStart); + final lastTick = dataToAxisUnitConverter + .invert(stepInfo.tickStart + stepInfo.stepSize * (tickCount - 1)); + final range = lastTick - firstTick; + // Calculate ticks if it is a better range or if preferred ticks have + // not been found yet. + if (range < selectedTicksRange || !foundPreferredTicks) { + final tickValues = _getTickValues(stepInfo, tickCount); + + if (viewportExtensionEnabled) { + mutableScale.viewportDomain = + new NumericExtents(firstTick, lastTick); + } + + // Create ticks from domain values. + final preferredTicks = createTicks(tickValues, + context: context, + graphicsFactory: graphicsFactory, + scale: viewportExtensionEnabled ? mutableScale : scale, + formatter: formatter, + formatterValueCache: formatterValueCache, + tickDrawStrategy: tickDrawStrategy, + stepSize: stepInfo.stepSize); + + // Request collision check from draw strategy. + final collisionReport = + tickDrawStrategy.collides(preferredTicks, orientation); + + // Don't choose colliding ticks unless it was our last resort + if (collisionReport.ticksCollide && tickCount > _minTickCount) { + continue; + } + // Only choose alternate ticks if preferred ticks is not found. + if (foundPreferredTicks && collisionReport.alternateTicksUsed) { + continue; + } + + ticks = collisionReport.alternateTicksUsed + ? collisionReport.ticks + : preferredTicks; + foundPreferredTicks = !collisionReport.alternateTicksUsed; + selectedTicksRange = range; + // If viewport extended, save the viewport used. + viewportDomain = mutableScale?.viewportDomain ?? scale.viewportDomain; + } + } + _setPreviousTickCalculationParameters(); + // If [viewportExtensionEnabled] and has changed, then set the scale's + // viewport to what was used to generate ticks. By only setting viewport + // when it has changed, we do not trigger the flag to recalculate scale. + if (viewportExtensionEnabled && scale.viewportDomain != viewportDomain) { + scale.viewportDomain = viewportDomain; + } + } + + return ticks; + } + + /// Checks whether the parameters that are used in determining the right set + /// of ticks changed from the last time we calculated ticks. If not we should + /// be able to use the cached ticks. + bool _hasTickParametersChanged() { + return _low != _prevLow || + _high != _prevHigh || + _rangeWidth != _prevRangeWidth || + _minTickCount != _prevMinTickCount || + _maxTickCount != _prevMaxTickCount || + dataIsInWholeNumbers != _prevDataIsInWholeNumbers; + } + + /// Save the last set of parameters used while determining ticks. + void _setPreviousTickCalculationParameters() { + _prevLow = _low; + _prevHigh = _high; + _prevRangeWidth = _rangeWidth; + _prevMinTickCount = _minTickCount; + _prevMaxTickCount = _maxTickCount; + _prevDataIsInWholeNumbers = dataIsInWholeNumbers; + } + + /// Calculates the domain extents that this provider will cover based on the + /// axis extents passed in and the settings in the numeric tick provider. + /// Stores the domain extents in [_low] and [_high]. + void _updateDomainExtents(NumericExtents axisExtents) { + _low = axisExtents.min; + _high = axisExtents.max; + + // Correct the extents for zero bound + if (zeroBound) { + _low = _low > 0.0 ? 0.0 : _low; + _high = _high < 0.0 ? 0.0 : _high; + } + + // Correct cases where high and low equal to give the tick provider an + // actual range to go off of when picking ticks. + if (_high == _low) { + if (_high == 0.0) { + // Corner case: the only values we've seen are zero, so lets just say + // the high is 1 and leave the low at zero. + _high = 1.0; + } else { + // The values are all the same, so assume a range of -5% to +5% from the + // single value. + if (_high > 0.0) { + _high = _high * 1.05; + _low = _low * 0.95; + } else { + // (high == low) < 0 + _high = _high * 0.95; + _low = _low * 1.05; + } + } + } + } + + /// Given [tickCount] and the domain range, finds the smallest tick increment, + /// chosen from power of 10 multiples of allowed steps, that covers the whole + /// data range. + _TickStepInfo _getStepsForTickCount(int tickCount, num high, num low) { + // A region is the space between ticks. + final regionCount = tickCount - 1; + + // If the range contains zero, ensure that zero is a tick. + if (high >= 0 && low <= 0) { + // determine the ratio of regions that are above the zero axis. + final posRegionRatio = (high > 0 ? min(1.0, high / (high - low)) : 0.0); + var positiveRegionCount = (regionCount * posRegionRatio).ceil(); + var negativeRegionCount = regionCount - positiveRegionCount; + // Ensure that negative regions are not excluded, unless there are no + // regions to spare. + if (negativeRegionCount == 0 && low < 0 && regionCount > 1) { + positiveRegionCount--; + negativeRegionCount++; + } + + // Determine the "favored" axis direction (the one which will control the + // ticks based on having a greater value / regions). + // + // Example: 13 / 3 (4.33 per tick) vs -5 / 1 (5 per tick) + // making -5 the favored number. A step size that includes this number + // ensures the other is also includes in the opposite direction. + final favorPositive = (high > 0 ? high / positiveRegionCount : 0).abs() > + (low < 0 ? low / negativeRegionCount : 0).abs(); + final favoredNum = (favorPositive ? high : low).abs(); + final favoredRegionCount = + favorPositive ? positiveRegionCount : negativeRegionCount; + final favoredTensBase = (_getEnclosingPowerOfTen(favoredNum)).abs(); + + // Check each step size and see if it would contain the "favored" value + for (double step in _allowedSteps) { + final tmpStepSize = _removeRoundingErrors(step * favoredTensBase); + + // If prefer whole number, then don't allow a step that isn't one. + if (dataIsInWholeNumbers && (tmpStepSize).round() != tmpStepSize) { + continue; + } + + // TODO: Skip steps that format to the same string. + // But wait until the last step to prevent the cost of the formatter. + // Potentially store the formatted strings in TickStepInfo? + if (tmpStepSize * favoredRegionCount >= favoredNum) { + double stepStart = negativeRegionCount > 0 + ? (-1 * tmpStepSize * negativeRegionCount) + : 0.0; + return new _TickStepInfo(tmpStepSize, stepStart); + } + } + } else { + // Find the range base to calculate step sizes. + final diffTensBase = _getEnclosingPowerOfTen(high - low); + // Walk the step sizes calculating a starting point and seeing if the high + // end is included in the range given that step size. + for (double step in _allowedSteps) { + final tmpStepSize = _removeRoundingErrors(step * diffTensBase); + + // If prefer whole number, then don't allow a step that isn't one. + if (dataIsInWholeNumbers && (tmpStepSize).round() != tmpStepSize) { + continue; + } + + // TODO: Skip steps that format to the same string. + // But wait until the last step to prevent the cost of the formatter. + double tmpStepStart = _getStepLessThan(low, tmpStepSize); + if (tmpStepStart + (tmpStepSize * regionCount) >= high) { + return new _TickStepInfo(tmpStepSize, tmpStepStart); + } + } + } + + return new _TickStepInfo(1.0, low.floorToDouble()); + } + + List _getTickValues(_TickStepInfo steps, int tickCount) { + final tickValues = new List(tickCount); + // We have our size and start, assign all the tick values to the given array. + for (int i = 0; i < tickCount; i++) { + tickValues[i] = dataToAxisUnitConverter.invert( + _removeRoundingErrors(steps.tickStart + (i * steps.stepSize))); + } + return tickValues; + } + + /// Given the axisDimensions update the tick counts given they are not fixed. + void _updateTickCounts() { + int tmpMaxNumMajorTicks; + int tmpMinNumMajorTicks; + + // If there is a desired tick range use it, if not calculate one. + if (_desiredMaxTickCount != null) { + tmpMinNumMajorTicks = max(_desiredMinTickCount, 2); + tmpMaxNumMajorTicks = max(_desiredMaxTickCount, tmpMinNumMajorTicks); + } else { + double minPixelsPerTick = MIN_DIPS_BETWEEN_TICKS.toDouble(); + tmpMinNumMajorTicks = 2; + tmpMaxNumMajorTicks = max(2, (_rangeWidth / minPixelsPerTick).floor()); + } + + // Don't blow away the previous array if it hasn't changed. + if (tmpMaxNumMajorTicks != _maxTickCount || + tmpMinNumMajorTicks != _minTickCount) { + _maxTickCount = tmpMaxNumMajorTicks; + _minTickCount = tmpMinNumMajorTicks; + } + } + + /// Returns the power of 10 which contains the [number]. + /// + /// If [number] is 0 returns 1. + /// Examples: + /// [number] of 63 returns 100 + /// [number] of -63 returns -100 + /// [number] of 0.63 returns 1 + static double _getEnclosingPowerOfTen(num number) { + if (number == 0) { + return 1.0; + } + + return pow(10, (LOG10E * log(number.abs())).ceil()) * + (number < 0.0 ? -1.0 : 1.0); + } + + /// Returns the step numerically less than the number by step increments. + static double _getStepLessThan(double number, double stepSize) { + if (number == 0.0 || stepSize == 0.0) { + return 0.0; + } + return (stepSize > 0.0 + ? (number / stepSize).floor() + : (number / stepSize).ceil()) * + stepSize; + } + + /// Attempts to slice off very small floating point rounding effects for the + /// given number. + /// + /// @param number the number to round. + /// @return the rounded number. + static double _removeRoundingErrors(double number) { + // sufficiently large multiplier to handle generating ticks on the order + // of 10^-9. + const multiplier = 1.0e9; + + return number > 100.0 + ? number.roundToDouble() + : (number * multiplier).roundToDouble() / multiplier; + } +} + +class _TickStepInfo { + double stepSize; + double tickStart; + + _TickStepInfo(this.stepSize, this.tickStart); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/ordinal_extents.dart b/charts_common/lib/src/chart/cartesian/axis/ordinal_extents.dart new file mode 100644 index 000000000..cf442beee --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/ordinal_extents.dart @@ -0,0 +1,44 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show HashSet; +import 'scale.dart' show Extents; + +/// A range of ordinals. +class OrdinalExtents extends Extents { + final List _range; + + /// The extents representing the ordinal values in [range]. + /// + /// The elements of [range] must all be unique. + /// + /// [D] is the domain class type for the elements in the extents. + OrdinalExtents(List range) : _range = range { + // This asserts that all elements in [range] are unique. + final uniqueValueCount = new HashSet.from(_range).length; + assert(uniqueValueCount == range.length); + } + + factory OrdinalExtents.all(List range) => new OrdinalExtents(range); + + bool get isEmpty => _range.isEmpty; + + /// The number of values inside this extent. + int get length => _range.length; + + String operator [](int index) => _range[index]; + + int indexOf(String value) => _range.indexOf(value); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/ordinal_scale.dart b/charts_common/lib/src/chart/cartesian/axis/ordinal_scale.dart new file mode 100644 index 000000000..08ea54ceb --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/ordinal_scale.dart @@ -0,0 +1,41 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'ordinal_scale_domain_info.dart' show OrdinalScaleDomainInfo; +import 'scale.dart' show MutableScale; +import 'ordinal_extents.dart' show OrdinalExtents; + +abstract class OrdinalScale extends MutableScale { + /// The current domain collection with all added unique values. + OrdinalScaleDomainInfo get domain; + + /// Sets the viewport of the scale based on the number of data points to show + /// and the starting domain value. + /// + /// [viewportDataSize] How many ordinal domain values to show in the viewport. + /// [startingDomain] The starting domain value of the viewport. Note that if + /// the starting domain is in terms of position less than [domainValuesToShow] + /// from the last domain value the viewport will be fixed to the last value + /// and not guaranteed that this domain value is the first in the viewport. + void setViewport(int viewportDataSize, String startingDomain); + + /// The number of full ordinal steps that fit in the viewport. + int get viewportDataSize; + + /// The first fully visible ordinal step within the viewport. + /// + /// Null if no domains exist. + String get viewportStartingDomain; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/ordinal_scale_domain_info.dart b/charts_common/lib/src/chart/cartesian/axis/ordinal_scale_domain_info.dart new file mode 100644 index 000000000..58963da60 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/ordinal_scale_domain_info.dart @@ -0,0 +1,77 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show HashMap; +import 'ordinal_extents.dart' show OrdinalExtents; + +/// A domain processor for [OrdinalScale]. +/// +/// [D] domain class type of the values being tracked. +/// +/// Unique domain values are kept, so duplicates will not increase the extent. +class OrdinalScaleDomainInfo { + int _index = 0; + + /// A map of domain value and the order it was added. + final _domainsToOrder = new HashMap(); + + /// A list of domain values kept to support [getDomainAtIndex]. + final _domainList = []; + + OrdinalScaleDomainInfo(); + + OrdinalScaleDomainInfo copy() { + return new OrdinalScaleDomainInfo() + .._domainsToOrder.addAll(_domainsToOrder) + .._index = _index + .._domainList.addAll(_domainList); + } + + void add(String domain) { + if (!_domainsToOrder.containsKey(domain)) { + _domainsToOrder[domain] = _index; + _index += 1; + _domainList.add(domain); + } + } + + int indexOf(String domain) => _domainsToOrder[domain]; + + String getDomainAtIndex(int index) { + assert(index >= 0); + assert(index < _index); + return _domainList[index]; + } + + List get domains => _domainList; + + String get first => _domainList.isEmpty ? null : _domainList.first; + + String get last => _domainList.isEmpty ? null : _domainList.last; + + bool get isEmpty => (_index == 0); + bool get isNotEmpty => !isEmpty; + + OrdinalExtents get extent => new OrdinalExtents.all(_domainList); + + int get size => _index; + + /// Clears all domain values. + void clear() { + _domainsToOrder.clear(); + _domainList.clear(); + _index = 0; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/ordinal_tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/ordinal_tick_provider.dart new file mode 100644 index 000000000..7d53f8d06 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/ordinal_tick_provider.dart @@ -0,0 +1,58 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import '../../common/chart_context.dart' show ChartContext; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import 'axis.dart' show AxisOrientation; +import 'ordinal_extents.dart' show OrdinalExtents; +import 'ordinal_scale.dart' show OrdinalScale; +import 'tick.dart' show Tick; +import 'tick_formatter.dart' show TickFormatter; +import 'draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import 'tick_provider.dart' show BaseTickProvider; + +/// A strategy for selecting ticks to draw given ordinal domain values. +class OrdinalTickProvider + extends BaseTickProvider { + const OrdinalTickProvider(); + + @override + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required List domainValues, + @required OrdinalScale scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }) { + return createTicks(scale.domain.domains, + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: formatterValueCache, + tickDrawStrategy: tickDrawStrategy); + } + + @override + bool operator ==(o) => o is OrdinalTickProvider; + + @override + int get hashCode => 31; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/scale.dart b/charts_common/lib/src/chart/cartesian/axis/scale.dart new file mode 100644 index 000000000..664256c8c --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/scale.dart @@ -0,0 +1,318 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' as math show max, min; + +/// Scale used to convert data input domain units to output range units. +/// +/// This is the immutable portion of the Scale definition. Used for converting +/// data from the dataset in domain units to an output in range units (likely +/// pixel range of the area to draw on). +/// +///

The Scale/MutableScale split is to show the intention of what you can or +/// should be doing with the scale during different stages of chart draw +/// process. +/// +/// [D] is the domain class type for the values passed in. +abstract class Scale { + /// Applies the scale function to the [domainValue]. + /// + /// Returns the pixel location for the given [domainValue] or null if the + /// domainValue could not be found/translated by this scale. + /// Non-numeric scales should be the only ones that can return null. + num operator [](D domainValue); + + /// Reverse application of the scale. + D reverse(double pixelLocation); + + /// Tests a [domainValue] to see if the scale can translate it. + /// + /// Returns true if the scale can translate the given domainValue. + /// (Ex: linear scales can translate any number, but ordinal scales can only + /// translate values previously passed in.) + bool canTranslate(D domainValue); + + /// Returns the previously set output range for the scale function. + ScaleOutputExtent get range; + + /// Returns the absolute width between the max and min range values. + int get rangeWidth; + + /// Returns the configuration used to determine the rangeBand. + /// + /// This is most often used to define the bar group width. + RangeBandConfig get rangeBandConfig; + + /// Returns the rangeBand width in pixels. + /// + /// The rangeBand is determined using the RangeBandConfig potentially with the + /// measured step size. This value is used as the bar group width. If + /// StepSizeConfig is set to auto detect, then you must wait until after + /// the chart's onPostLayout phase before you'll get a valid number. + double get rangeBand; + + /// Returns the stepSize width in pixels. + /// + /// The step size is determined using the [StepSizeConfig]. + double get stepSize; + + /// Tests whether the given [domainValue] is within the axis' range. + /// + /// Returns < 0 if the [domainValue] would plot before the viewport, 0 if it + /// would plot within the viewport and > 0 if it would plot beyond the + /// viewport of the axis. + int compareDomainValueToViewport(D domainValue); + + /// Returns true if the given [rangeValue] point is within the output range. + /// + /// Not to be confused with the start and end of the domain. + bool isRangeValueWithinViewport(double rangeValue); + + /// Returns the current viewport scale. + /// + /// A scale of 1.0 would map the data directly to the output range, while a + /// value of 2.0 would map the data to an output of double the range so you + /// only see half the data in the viewport. This is the equivalent to + /// zooming. Its value is likely >= 1.0. + double get viewportScalingFactor; + + /// Returns the current pixel viewport offset + /// + /// The translate is used by the scale function when it applies the scale. + /// This is the equivalent to panning. Its value is likely <= 0 to pan the + /// data to the left. + double get viewportTranslatePx; + + /// Returns the domain extent visible in the viewport of the drawArea. + E get viewportDomain; + + /// Returns a mutable copy of the scale. + /// + /// Mutating the returned scale will not effect the original one. + MutableScale copy(); +} + +/// Mutable extension of the [Scale] definition. +/// +/// Used for converting data from the dataset to some range (likely pixel range) +/// of the area to draw on. +/// +/// [D] the domain class type for the values passed in. +abstract class MutableScale extends Scale { + /// Reset the domain for this [Scale]. + void resetDomain(); + + /// Reset the viewport settings for this [Scale]. + void resetViewportSettings(); + + /// Add [domainValue] to this [Scale]'s domain. + /// + /// Domains should be added in order to allow proper stepSize detection. + /// [domainValue] is the data value to add to the scale used to update the + /// domain extent. + void addDomain(D domainValue); + + /// Sets the output range to use for the scale's conversion. + /// + /// The range start is mapped to the domain's min and the range end is + /// mapped to the domain's max for the conversion using the domain nicing + /// function. + /// + /// [extent] is the extent of the range which will likely be the pixel + /// range of the drawing area to convert to. + set range(ScaleOutputExtent extent); + + /// Configures the zoom and translate. + /// + /// [viewportScale] is the zoom factor to use, likely >= 1.0 where 1.0 maps + /// the complete data extents to the output range, and 2.0 only maps half the + /// data to the output range. + /// + /// [viewportTranslatePx] is the translate/pan to use in pixel units, + /// likely <= 0 which shifts the start of the data before the edge of the + /// chart giving us a pan. + void setViewportSettings(double viewportScale, double viewportTranslatePx); + + /// Sets the domain extent visible in the viewport of the drawArea. + /// + /// Invalidates the viewportScale & viewportTranslatePx. + set viewportDomain(E extents); + + /// Sets the configuration used to determine the rangeBand (bar group width). + set rangeBandConfig(RangeBandConfig barGroupWidthConfig); + + /// Sets the method for determining the step size. + /// + /// This is the domain space between data points. + StepSizeConfig get stepSizeConfig; + set stepSizeConfig(StepSizeConfig config); +} + +/// Tuple of the output for a scale in pixels from [start] to [end] inclusive. +/// +/// It is different from [Extent] because it focuses on start and end and not +/// min and max, meaning that start could be greater or less than end. +class ScaleOutputExtent { + final int start; + final int end; + + const ScaleOutputExtent(this.start, this.end); + + int get min => math.min(start, end); + int get max => math.max(start, end); + + bool containsValue(double value) => value >= min && value <= max; + + /// Returns the difference between the extents. + /// + /// If the [end] is less than the [start] (think vertical measure axis), then + /// this will correctly return a negative value. + int get diff => end - start; + + /// Returns the width of the extent. + int get width => diff.abs(); + + @override + bool operator ==(other) => + other is ScaleOutputExtent && start == other.start && end == other.end; + + @override + int get hashCode => start.hashCode + (end.hashCode * 31); + + @override + String toString() => "ScaleOutputRange($start, $end)"; +} + +/// Type of RangeBand used to determine the rangeBand size units. +enum RangeBandType { + /// No rangeBand (not suitable for bars or step line charts). + none, + + /// Size is specified in pixel units. + fixedPixel, + + /// Size is specified domain scale units. + fixedDomain, + + /// Size is a percentage of the minimum step size between points. + fixedPercentOfStep, + + /// Size is a style pack assigned percentage of the minimum step size between + /// points. + styleAssignedPercentOfStep, + + /// Size is subtracted from the minimum step size between points in pixel + /// units. + fixedPixelSpaceFromStep, +} + +/// Defines the method for calculating the rangeBand of the Scale. +/// +/// The rangeBand is used to determine the width of a group of bars. The term +/// rangeBand comes from the d3 JavaScript library which the JS library uses +/// internally. +/// +///

RangeBandConfig is immutable, See factory methods for creating one. +class RangeBandConfig { + final RangeBandType type; + + /// The width of the band in units specified by the bandType. + final double size; + + /// Creates a rangeBand definition of zero, no rangeBand. + const RangeBandConfig.none() + : type = RangeBandType.none, + size = 0.0; + + /// Creates a fixed rangeBand definition in pixel width. + /// + /// Used to determine a bar width or a step width in the line renderer. + const RangeBandConfig.fixedPixel(double pixels) + : type = RangeBandType.fixedPixel, + size = pixels; + + /// Creates a fixed rangeBand definition in domain unit width. + /// + /// Used to determine a bar width or a step width in the line renderer. + const RangeBandConfig.fixedDomain(double domainSize) + : type = RangeBandType.fixedDomain, + size = domainSize; + + /// Creates a config that defines the rangeBand as equal to the stepSize. + const RangeBandConfig.stepChartBand() + : type = RangeBandType.fixedPercentOfStep, + size = 1.0; + + /// Creates a config that defines the rangeBand as percentage of the stepSize. + /// + /// [percentOfStepWidth] is the percentage of the step from 0.0 - 1.0. + RangeBandConfig.percentOfStep(double percentOfStepWidth) + : type = RangeBandType.fixedPercentOfStep, + size = percentOfStepWidth { + assert(percentOfStepWidth >= 0 && percentOfStepWidth <= 1.0); + } + + /// Creates a config that assigns the rangeBand according to the stylepack. + /// + ///

Note: renderers can detect this setting and update the percent based on + /// the number of series in their preprocess. + RangeBandConfig.styleAssignedPercent([int seriesCount = 1]) + : type = RangeBandType.styleAssignedPercentOfStep, + // TODO: retrieve value from the stylepack once available. + size = 0.65; + + /// Creates a config that defines the rangeBand as the stepSize - pixels. + /// + /// Where fixedPixels() gave you a constant rangBand in pixels, this will give + /// you a constant space between rangeBands in pixels. + const RangeBandConfig.fixedPixelSpaceBetweenStep(double pixels) + : type = RangeBandType.fixedPixelSpaceFromStep, + size = pixels; +} + +/// Type of step size calculation to use. +enum StepSizeType { autoDetect, fixedDomain, fixedPixels } + +/// Defines the method for calculating the stepSize between points. +/// +/// Typically auto will work fine in most cases, but if your data is +/// irregular or you only have one data point, then you may want to override the +/// stepSize detection specifying the exact expected stepSize. +class StepSizeConfig { + final StepSizeType type; + final double size; + + /// Creates a StepSizeConfig that calculates step size based on incoming data. + /// + /// The stepSize is determined is calculated by detecting the smallest + /// distance between two adjacent data points. This may not be suitable if + /// you have irregular data or just a single data point. + const StepSizeConfig.auto() + : type = StepSizeType.autoDetect, + size = 0.0; + + /// Creates a StepSizeConfig specifying the exact step size in pixel units. + const StepSizeConfig.fixedPixels(double pixels) + : type = StepSizeType.fixedPixels, + size = pixels; + + /// Creates a StepSizeConfig specifying the exact step size in domain units. + const StepSizeConfig.fixedDomain(double domainSize) + : type = StepSizeType.fixedDomain, + size = domainSize; +} + +// TODO: make other extent subclasses plural. +abstract class Extents {} diff --git a/charts_common/lib/src/chart/cartesian/axis/simple_ordinal_scale.dart b/charts_common/lib/src/chart/cartesian/axis/simple_ordinal_scale.dart new file mode 100644 index 000000000..09be07dc6 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/simple_ordinal_scale.dart @@ -0,0 +1,326 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show min, max; + +import 'ordinal_extents.dart' show OrdinalExtents; +import 'ordinal_scale.dart' show OrdinalScale; +import 'ordinal_scale_domain_info.dart' show OrdinalScaleDomainInfo; +import 'scale.dart' + show + RangeBandConfig, + RangeBandType, + StepSizeConfig, + StepSizeType, + ScaleOutputExtent; + +/// Scale that converts ordinal values of type [D] to a given range output. +/// +/// A `SimpleOrdinalScale` is used to map values from its domain to the +/// available pixel range of the chart. Typically used for bar charts where the +/// width of the bar is [rangeBand] and the position of the bar is retrieved +/// by [[]]. +class SimpleOrdinalScale implements OrdinalScale { + final _stepSizeConfig = new StepSizeConfig.auto(); + OrdinalScaleDomainInfo _domain; + ScaleOutputExtent _range = new ScaleOutputExtent(0, 1); + double _viewportScale = 1.0; + double _viewportTranslatePx = 0.0; + RangeBandConfig _rangeBandConfig = new RangeBandConfig.styleAssignedPercent(); + + bool _scaleChanged = true; + double _cachedStepSizePixels; + double _cachedRangeBandShift; + double _cachedRangeBandSize; + + SimpleOrdinalScale() : _domain = new OrdinalScaleDomainInfo(); + + SimpleOrdinalScale._copy(SimpleOrdinalScale other) + : _domain = other._domain.copy(), + _range = new ScaleOutputExtent(other._range.start, other._range.end), + _viewportScale = other._viewportScale, + _viewportTranslatePx = other._viewportTranslatePx, + _rangeBandConfig = other._rangeBandConfig; + + @override + double get rangeBand { + if (_scaleChanged) { + _updateScale(); + } + + return _cachedRangeBandSize; + } + + @override + double get stepSize { + if (_scaleChanged) { + _updateScale(); + } + + return _cachedStepSizePixels; + } + + @override + set rangeBandConfig(RangeBandConfig barGroupWidthConfig) { + if (barGroupWidthConfig == null) { + throw new ArgumentError.notNull('RangeBandConfig must not be null.'); + } + + if (barGroupWidthConfig.type == RangeBandType.fixedDomain || + barGroupWidthConfig.type == RangeBandType.none) { + throw new ArgumentError( + 'barGroupWidthConfig must not be NONE or FIXED_DOMAIN'); + } + + _rangeBandConfig = barGroupWidthConfig; + _scaleChanged = true; + } + + @override + RangeBandConfig get rangeBandConfig => _rangeBandConfig; + + @override + set stepSizeConfig(StepSizeConfig config) { + if (config != null && config.type != StepSizeType.autoDetect) { + throw new ArgumentError( + "Ordinal scales only support StepSizeConfig of type Auto"); + } + // Nothing is set because only auto is supported. + } + + @override + StepSizeConfig get stepSizeConfig => _stepSizeConfig; + + /// Converts [domainValue] to the position to place the band/bar. + /// + /// Returns 0 if not found. + @override + num operator [](String domainValue) { + if (_scaleChanged) { + _updateScale(); + } + + final i = _domain.indexOf(domainValue); + if (i != null) { + return viewportTranslatePx + + _range.start + + _cachedRangeBandShift + + (_cachedStepSizePixels * i); + } + // If it wasn't found + return 0; + } + + @override + String reverse(double pixelLocation) { + final index = (pixelLocation - + viewportTranslatePx - + _range.start - + _cachedRangeBandShift) / + _cachedStepSizePixels; + // The last pixel belongs in the last step even if it tries to round up. + return _domain.getDomainAtIndex(min(index.round(), domain.size - 1)); + } + + @override + bool canTranslate(String domainValue) => + (_domain.indexOf(domainValue) != null); + + @override + OrdinalScaleDomainInfo get domain => _domain; + + /// Update the scale to include [domainValue]. + @override + void addDomain(String domainValue) { + _domain.add(domainValue); + _scaleChanged = true; + } + + @override + set range(ScaleOutputExtent extent) { + _range = extent; + _scaleChanged = true; + } + + @override + ScaleOutputExtent get range => _range; + + @override + resetDomain() { + _domain.clear(); + _scaleChanged = true; + } + + @override + resetViewportSettings() { + _viewportScale = 1.0; + _viewportTranslatePx = 0.0; + _scaleChanged = true; + } + + @override + int get rangeWidth => (range.start - range.end).abs().toInt(); + + @override + double get viewportScalingFactor => _viewportScale; + + @override + double get viewportTranslatePx => _viewportTranslatePx; + + @override + void setViewportSettings(double viewportScale, double viewportTranslatePx) { + _viewportScale = viewportScale; + _viewportTranslatePx = + min(0.0, max(rangeWidth * (1.0 - viewportScale), viewportTranslatePx)); + + _scaleChanged = true; + } + + @override + void setViewport(int viewportDataSize, String startingDomain) { + if (viewportDataSize <= 0) { + throw new ArgumentError('viewportDataSize can' 't be less than 1.'); + } + setViewportSettings(1.0, 0.0); + _updateScale(); + if (_domain.isEmpty) { + return; + } + + // Update the scale with zoom level to help find the correct translate. + setViewportSettings( + _domain.size / min(viewportDataSize, _domain.size), 0.0); + _updateScale(); + final domainIndex = _domain.indexOf(startingDomain); + if (domainIndex != null) { + // Update the translate so that the scale starts half a step before the + // chosen domain. + final viewportTranslatePx = + -(_cachedStepSizePixels * _domain.indexOf(startingDomain)); + setViewportSettings(_viewportScale, viewportTranslatePx); + } + } + + @override + int get viewportDataSize { + if (_scaleChanged) { + _updateScale(); + } + + return _domain.isEmpty ? 0 : (rangeWidth ~/ _cachedStepSizePixels); + } + + @override + String get viewportStartingDomain { + if (_scaleChanged) { + _updateScale(); + } + if (_domain.isEmpty) { + return null; + } + return _domain.getDomainAtIndex( + (-_viewportTranslatePx / _cachedStepSizePixels).ceil().toInt()); + } + + @override + set viewportDomain(OrdinalExtents extents) { + // TODO: Implement + throw new UnimplementedError('no domain viewport support for ordinal yet.'); + } + + @override + OrdinalExtents get viewportDomain { + // TODO: Implement + throw new UnimplementedError('no domain viewport support for ordinal yet.'); + } + + @override + bool isRangeValueWithinViewport(double rangeValue) { + return range != null && rangeValue >= range.min && rangeValue <= range.max; + } + + @override + int compareDomainValueToViewport(String domainValue) { + // TODO: This currently works because range defaults to 0-1 + // This needs to be looked into further. + var i = _domain.indexOf(domainValue); + if (i != null && range != null) { + var domainPx = this[domainValue]; + if (domainPx < range.min) { + return -1; + } + if (domainPx > range.max) { + return 1; + } + return 0; + } + return -1; + } + + @override + SimpleOrdinalScale copy() => new SimpleOrdinalScale._copy(this); + + void _updateCachedFields( + double stepSizePixels, double rangeBandPixels, double rangeBandShift) { + _cachedStepSizePixels = stepSizePixels; + _cachedRangeBandSize = rangeBandPixels; + _cachedRangeBandShift = rangeBandShift; + + // TODO: When there are horizontal bars increasing from where + // the domain and measure axis intersects but the desired behavior is + // flipped. The plan is to fix this by fixing code to flip the range in the + // code. + + // If range start is less than range end, then the domain is calculated by + // adding the band width. If range start is greater than range end, then the + // domain is calculated by subtracting from the band width (ex. horizontal + // bar charts where first series is at the bottom of the chart). + if (range.start > range.end) { + _cachedStepSizePixels *= -1; + _cachedRangeBandShift *= -1; + } + + _scaleChanged = false; + } + + void _updateScale() { + final stepSizePixels = _domain.isEmpty + ? 0.0 + : _viewportScale * (rangeWidth.toDouble() / _domain.size.toDouble()); + double rangeBandPixels; + + switch (rangeBandConfig.type) { + case RangeBandType.fixedPixel: + rangeBandPixels = rangeBandConfig.size.toDouble(); + break; + case RangeBandType.fixedPixelSpaceFromStep: + var spaceInPixels = rangeBandConfig.size.toDouble(); + rangeBandPixels = max(0.0, stepSizePixels - spaceInPixels); + break; + case RangeBandType.styleAssignedPercentOfStep: + case RangeBandType.fixedPercentOfStep: + var percent = rangeBandConfig.size.toDouble(); + rangeBandPixels = stepSizePixels * percent; + break; + case RangeBandType.fixedDomain: + case RangeBandType.none: + default: + throw new StateError('RangeBandType must not be NONE or FIXED_DOMAIN'); + break; + } + + _updateCachedFields(stepSizePixels, rangeBandPixels, stepSizePixels / 2.0); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/spec/axis_spec.dart b/charts_common/lib/src/chart/cartesian/axis/spec/axis_spec.dart new file mode 100644 index 000000000..43cb2995c --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/spec/axis_spec.dart @@ -0,0 +1,158 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +import '../../../common/chart_context.dart' show ChartContext; +import '../../../../common/color.dart' show Color; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import '../axis.dart' show Axis; +import '../tick_provider.dart' show TickProvider; +import '../tick_formatter.dart' show TickFormatter; +import '../scale.dart' show MutableScale, Extents; + +@immutable +class AxisSpec> { + final bool showAxisLine; + final RenderSpec renderSpec; + final TickProviderSpec tickProviderSpec; + final TickFormatterSpec tickFormatterSpec; + + AxisSpec({ + this.renderSpec, + this.tickProviderSpec, + this.tickFormatterSpec, + this.showAxisLine, + }); + + configure(Axis axis, ChartContext context, + GraphicsFactory graphicsFactory) { + if (showAxisLine != null) { + axis.forceDrawAxisLine = showAxisLine; + } + + if (renderSpec != null) { + axis.tickDrawStrategy = + renderSpec.createDrawStrategy(context, graphicsFactory); + } + + if (tickProviderSpec != null) { + axis.tickProvider = tickProviderSpec.createTickProvider(context); + } + + if (tickFormatterSpec != null) { + axis.tickFormatter = tickFormatterSpec.createTickFormatter(context); + } + } + + @override + bool operator ==(Object other) => + other is AxisSpec && + renderSpec == other.renderSpec && + tickProviderSpec == other.tickProviderSpec && + tickFormatterSpec == other.tickFormatterSpec && + showAxisLine == other.showAxisLine; + + @override + int get hashCode { + int hashcode = renderSpec?.hashCode ?? 0; + hashcode = (hashcode * 37) + tickProviderSpec.hashCode; + hashcode = (hashcode * 37) + tickFormatterSpec.hashCode; + hashcode = (hashcode * 37) + showAxisLine.hashCode; + return hashcode; + } +} + +@immutable +abstract class TickProviderSpec> { + TickProvider createTickProvider(ChartContext context); +} + +@immutable +abstract class TickFormatterSpec { + TickFormatter createTickFormatter(ChartContext context); +} + +@immutable +abstract class RenderSpec { + TickDrawStrategy createDrawStrategy( + ChartContext context, GraphicsFactory graphicFactory); +} + +@immutable +class TextStyleSpec { + final String fontFamily; + final int fontSize; + final Color color; + + TextStyleSpec({this.fontFamily, this.fontSize, this.color}); + + @override + bool operator ==(Object other) { + return other is TextStyleSpec && + fontFamily == other.fontFamily && + fontSize == other.fontSize && + color == other.color; + } + + @override + int get hashCode { + int hashcode = fontFamily?.hashCode ?? 0; + hashcode = (hashcode * 37) + fontSize?.hashCode ?? 0; + hashcode = (hashcode * 37) + color?.hashCode ?? 0; + return hashcode; + } +} + +@immutable +class LineStyleSpec { + final Color color; + final int thickness; + + LineStyleSpec({this.color, this.thickness}); + + @override + bool operator ==(Object other) { + return other is LineStyleSpec && + color == other.color && + thickness == other.thickness && + color == other.color; + } + + @override + int get hashCode { + int hashcode = color?.hashCode ?? 0; + hashcode = (hashcode * 37) + thickness?.hashCode ?? 0; + return hashcode; + } +} + +enum TickLabelAnchor { + before, + centered, + after, + + /// The top most tick draws all text under the location. + /// The bottom most tick draws all text above the location. + /// The rest of the ticks are centered. + inside, +} + +enum TickLabelJustification { + inside, + outside, +} diff --git a/charts_common/lib/src/chart/cartesian/axis/spec/date_time_axis_spec.dart b/charts_common/lib/src/chart/cartesian/axis/spec/date_time_axis_spec.dart new file mode 100644 index 000000000..c719d6426 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/spec/date_time_axis_spec.dart @@ -0,0 +1,243 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +import '../../../common/chart_context.dart' show ChartContext; +import '../static_tick_provider.dart' show StaticTickProvider; +import '../time/auto_adjusting_date_time_tick_provider.dart' + show AutoAdjustingDateTimeTickProvider; +import '../time/date_time_tick_formatter.dart' show DateTimeTickFormatter; +import '../time/hour_tick_formatter.dart' show HourTickFormatter; +import '../time/time_tick_formatter.dart' show TimeTickFormatter; +import '../time/time_tick_formatter_impl.dart' + show CalendarField, TimeTickFormatterImpl; +import '../time/date_time_extents.dart' show DateTimeExtents; +import '../time/date_time_scale.dart' show DateTimeScale; +import 'axis_spec.dart' + show AxisSpec, TickProviderSpec, TickFormatterSpec, RenderSpec; +import 'tick_spec.dart' show TickSpec; + +/// [AxisSpec] specialized for Timeseries charts. +@immutable +class DateTimeAxisSpec + extends AxisSpec { + /// Creates a [AxisSpec] that specialized for timeseries charts. + /// + /// [renderSpec] spec used to configure how the ticks and labels + /// actually render. Possible values are [GridlineRendererSpec], + /// [SmallTickRendererSpec] & [NoneRenderSpec]. Make sure that the + /// given to the RenderSpec is of type [DateTime] for Timeseries. + /// [tickProviderSpec] spec used to configure what ticks are generated. + /// [tickFormatterSpec] spec used to configure how the tick labels + /// are formatted. + /// [showAxisLine] override to force the axis to draw the axis + /// line. + DateTimeAxisSpec({ + RenderSpec renderSpec, + DateTimeTickProviderSpec tickProviderSpec, + DateTimeTickFormatterSpec tickFormatterSpec, + bool showAxisLine, + }) : super( + renderSpec: renderSpec, + tickProviderSpec: tickProviderSpec, + tickFormatterSpec: tickFormatterSpec, + showAxisLine: showAxisLine); + + @override + bool operator ==(Object other) => + other is DateTimeAxisSpec && super == (other); +} + +abstract class DateTimeTickProviderSpec + extends TickProviderSpec {} + +abstract class DateTimeTickFormatterSpec extends TickFormatterSpec {} + +/// [TickProviderSpec] that sets up the automatically assigned time ticks based +/// on the extents of your data. +@immutable +class AutoDateTimeTickProviderSpec implements DateTimeTickProviderSpec { + final bool includeTime; + + /// Creates a [TickProviderSpec] that dynamically chooses ticks based on the + /// extents of the data. + /// + /// [includeTime] - flag that indicates whether the time should be + /// included when choosing appropriate tick intervals. + AutoDateTimeTickProviderSpec({this.includeTime = true}); + + @override + AutoAdjustingDateTimeTickProvider createTickProvider(ChartContext context) { + if (includeTime) { + return new AutoAdjustingDateTimeTickProvider.createDefault( + context.dateTimeFactory); + } else { + return new AutoAdjustingDateTimeTickProvider.createWithoutTime( + context.dateTimeFactory); + } + } + + @override + bool operator ==(Object other) => + other is AutoDateTimeTickProviderSpec && includeTime == other.includeTime; + + @override + int get hashCode => includeTime?.hashCode ?? 0; +} + +/// [TickProviderSpec] that allows you to specific the ticks to be used. +@immutable +class StaticDateTimeTickProviderSpec implements DateTimeTickProviderSpec { + final List> tickSpecs; + + StaticDateTimeTickProviderSpec(this.tickSpecs); + + @override + StaticTickProvider + createTickProvider(ChartContext context) => + new StaticTickProvider( + tickSpecs); + + @override + bool operator ==(Object other) => + other is StaticDateTimeTickProviderSpec && tickSpecs == other.tickSpecs; + + @override + int get hashCode => tickSpecs.hashCode; +} + +/// Formatters for a single level of the [DateTimeTickFormatterSpec]. +@immutable +class TimeFormatterSpec { + final String format; + final String transitionFormat; + final String noonFormat; + + /// Creates a formatter for a particular granularity of data. + /// + /// [format] [DateFormat] format string used to format non-transition ticks. + /// The string is given to the dateTimeFactory to support i18n formatting. + /// [transitionFormat] [DateFormat] format string used to format transition + /// ticks. Examples of transition ticks: + /// Day ticks would have a transition tick at month boundaries. + /// Hour ticks would have a transition tick at day boundaries. + /// The first tick is typically a transition tick. + /// [noonFormat] [DateFormat] format string used only for formatting hours + /// in the event that you want to format noon differently than other + /// hours (ie: [10, 11, 12p, 1, 2, 3]). + TimeFormatterSpec({this.format, this.transitionFormat, this.noonFormat}); + + @override + bool operator ==(Object other) => + other is TimeFormatterSpec && + format == other.format && + transitionFormat == other.transitionFormat && + noonFormat == other.noonFormat; + + @override + int get hashCode { + int hashcode = format?.hashCode ?? 0; + hashcode = (hashcode * 37) + transitionFormat?.hashCode ?? 0; + hashcode = (hashcode * 37) + noonFormat?.hashCode ?? 0; + return hashCode; + } +} + +/// [TickFormatterSpec] that automatically chooses the appropriate level of +/// formatting based on the tick stepSize. Each level of date granularity has +/// its own [TimeFormatterSpec] used to specify the formatting strings at that +/// level. +@immutable +class AutoDateTimeTickFormatterSpec implements DateTimeTickFormatterSpec { + final TimeFormatterSpec minute; + final TimeFormatterSpec hour; + final TimeFormatterSpec day; + final TimeFormatterSpec month; + final TimeFormatterSpec year; + + /// Creates a [TickFormatterSpec] that automatically chooses the formatting + /// given the individual [TimeFormatterSpec] formatters that are set. + /// + /// There is a default formatter for each level that is configurable, but + /// by specifying a level here it replaces the default for that particular + /// granularity. This is useful for swapping out one or all of the formatters. + AutoDateTimeTickFormatterSpec( + {this.minute, this.hour, this.day, this.month, this.year}); + + @override + DateTimeTickFormatter createTickFormatter(ChartContext context) { + final Map map = {}; + + if (minute != null) { + map[DateTimeTickFormatter.MINUTE] = + _makeFormatter(minute, CalendarField.hourOfDay, context); + } + if (hour != null) { + map[DateTimeTickFormatter.HOUR] = + _makeFormatter(hour, CalendarField.date, context); + } + if (day != null) { + map[23 * DateTimeTickFormatter.HOUR] = + _makeFormatter(day, CalendarField.month, context); + } + if (month != null) { + map[28 * DateTimeTickFormatter.DAY] = + _makeFormatter(month, CalendarField.year, context); + } + if (year != null) { + map[364 * DateTimeTickFormatter.DAY] = + _makeFormatter(year, CalendarField.year, context); + } + + return new DateTimeTickFormatter(context.dateTimeFactory, overrides: map); + } + + TimeTickFormatterImpl _makeFormatter(TimeFormatterSpec spec, + CalendarField transitionField, ChartContext context) { + if (spec.noonFormat != null) { + return new HourTickFormatter( + dateTimeFactory: context.dateTimeFactory, + simpleFormat: spec.format, + transitionFormat: spec.transitionFormat, + noonFormat: spec.noonFormat); + } else { + return new TimeTickFormatterImpl( + dateTimeFactory: context.dateTimeFactory, + simpleFormat: spec.format, + transitionFormat: spec.transitionFormat, + transitionField: transitionField); + } + } + + @override + bool operator ==(Object other) => + other is AutoDateTimeTickFormatterSpec && + minute == other.minute && + hour == other.hour && + day == other.day && + month == other.month && + year == other.year; + + @override + int get hashCode { + int hashcode = minute?.hashCode ?? 0; + hashcode = (hashcode * 37) + hour?.hashCode ?? 0; + hashcode = (hashcode * 37) + day?.hashCode ?? 0; + hashcode = (hashcode * 37) + month?.hashCode ?? 0; + hashcode = (hashcode * 37) + year?.hashCode ?? 0; + return hashCode; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/spec/numeric_axis_spec.dart b/charts_common/lib/src/chart/cartesian/axis/spec/numeric_axis_spec.dart new file mode 100644 index 000000000..704891620 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/spec/numeric_axis_spec.dart @@ -0,0 +1,172 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; +import 'package:intl/intl.dart'; + +import '../../../common/chart_context.dart' show ChartContext; +import '../numeric_tick_provider.dart' show NumericTickProvider; +import '../static_tick_provider.dart' show StaticTickProvider; +import '../tick_formatter.dart' show NumericTickFormatter; +import '../numeric_extents.dart' show NumericExtents; +import '../numeric_scale.dart' show NumericScale; +import 'axis_spec.dart' + show AxisSpec, TickProviderSpec, TickFormatterSpec, RenderSpec; +import 'tick_spec.dart' show TickSpec; + +/// [AxisSpec] specialized for numeric/continuous axes like the measure axis. +@immutable +class NumericAxisSpec extends AxisSpec { + /// Creates a [AxisSpec] that specialized for numeric data. + /// + /// [renderSpec] spec used to configure how the ticks and labels + /// actually render. Possible values are [GridlineRendererSpec], + /// [SmallTickRendererSpec] & [NoneRenderSpec]. Make sure that the + /// given to the RenderSpec is of type [num] when using this spec. + /// [tickProviderSpec] spec used to configure what ticks are generated. + /// [tickFormatterSpec] spec used to configure how the tick labels are + /// formatted. + /// [showAxisLine] override to force the axis to draw the axis line. + NumericAxisSpec({ + RenderSpec renderSpec, + NumericTickProviderSpec tickProviderSpec, + NumericTickFormatterSpec tickFormatterSpec, + bool showAxisLine, + }) : super( + renderSpec: renderSpec, + tickProviderSpec: tickProviderSpec, + tickFormatterSpec: tickFormatterSpec, + showAxisLine: showAxisLine); + + @override + bool operator ==(Object other) => + other is NumericAxisSpec && super == (other); +} + +abstract class NumericTickProviderSpec + extends TickProviderSpec {} + +abstract class NumericTickFormatterSpec extends TickFormatterSpec {} + +@immutable +class BasicNumericTickProviderSpec implements NumericTickProviderSpec { + final bool zeroBound; + final bool dataIsInWholeNumbers; + final int desiredTickCount; + final int desiredMinTickCount; + final int desiredMaxTickCount; + + /// Creates a [TickProviderSpec] that dynamically chooses the number of + /// ticks based on the extents of the data. + /// + /// [zeroBound] automatically include zero in the data range. + /// [dataIsInWholeNumbers] skip over ticks that would produce + /// fractional ticks that don't make sense for the domain (ie: headcount). + /// [desiredTickCount] the fixed number of ticks to try to make. Convenience + /// that sets [desiredMinTickCount] and [desiredMaxTickCount] the same. + /// Both min and max win out if they are set along with + /// [desiredTickCount]. + /// [desiredMinTickCount] automatically choose the best tick + /// count to produce the 'nicest' ticks but make sure we have this many. + /// [desiredMaxTickCount] automatically choose the best tick + /// count to produce the 'nicest' ticks but make sure we don't have more + /// than this many. + BasicNumericTickProviderSpec( + {this.zeroBound, + this.dataIsInWholeNumbers, + this.desiredTickCount, + this.desiredMinTickCount, + this.desiredMaxTickCount}); + + @override + NumericTickProvider createTickProvider(ChartContext context) { + final provider = new NumericTickProvider(); + if (zeroBound != null) { + provider.zeroBound = zeroBound; + } + if (dataIsInWholeNumbers != null) { + provider.dataIsInWholeNumbers = dataIsInWholeNumbers; + } + + if (desiredMinTickCount != null || + desiredMaxTickCount != null || + desiredTickCount != null) { + provider.setTickCount(desiredMaxTickCount ?? desiredTickCount ?? 10, + desiredMinTickCount ?? desiredTickCount ?? 2); + } + return provider; + } + + @override + bool operator ==(Object other) => + other is BasicNumericTickProviderSpec && + zeroBound == other.zeroBound && + dataIsInWholeNumbers == other.dataIsInWholeNumbers && + desiredTickCount == other.desiredTickCount && + desiredMinTickCount == other.desiredMinTickCount && + desiredMaxTickCount == other.desiredMaxTickCount; + + @override + int get hashCode { + int hashcode = zeroBound?.hashCode ?? 0; + hashcode = (hashcode * 37) + dataIsInWholeNumbers?.hashCode ?? 0; + hashcode = (hashcode * 37) + desiredTickCount?.hashCode ?? 0; + hashcode = (hashcode * 37) + desiredMinTickCount?.hashCode ?? 0; + hashcode = (hashcode * 37) + desiredMaxTickCount?.hashCode ?? 0; + return hashCode; + } +} + +/// [TickProviderSpec] that allows you to specific the ticks to be used. +@immutable +class StaticNumericTickProviderSpec implements NumericTickProviderSpec { + final List> tickSpecs; + + StaticNumericTickProviderSpec(this.tickSpecs); + + @override + StaticTickProvider createTickProvider( + ChartContext context) => + new StaticTickProvider(tickSpecs); + + @override + bool operator ==(Object other) => + other is StaticNumericTickProviderSpec && tickSpecs == other.tickSpecs; + + @override + int get hashCode => tickSpecs.hashCode; +} + +@immutable +class BasicNumericTickFormatterSpec implements NumericTickFormatterSpec { + final NumberFormat numberFormat; + + /// Simple [TickFormatterSpec] that delegates formatting to the given + /// [NumberFormat]. + BasicNumericTickFormatterSpec(this.numberFormat); + + @override + NumericTickFormatter createTickFormatter(ChartContext context) => + new NumericTickFormatter(numberFormat: numberFormat); + + @override + bool operator ==(Object other) { + return other is BasicNumericTickFormatterSpec && + numberFormat == other.numberFormat; + } + + @override + int get hashCode => numberFormat.hashCode; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/spec/ordinal_axis_spec.dart b/charts_common/lib/src/chart/cartesian/axis/spec/ordinal_axis_spec.dart new file mode 100644 index 000000000..d7a8b9fc9 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/spec/ordinal_axis_spec.dart @@ -0,0 +1,110 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +import '../../../common/chart_context.dart' show ChartContext; +import '../ordinal_tick_provider.dart' show OrdinalTickProvider; +import '../static_tick_provider.dart' show StaticTickProvider; +import '../tick_formatter.dart' show OrdinalTickFormatter; +import '../ordinal_extents.dart' show OrdinalExtents; +import '../ordinal_scale.dart' show OrdinalScale; +import 'axis_spec.dart' + show AxisSpec, TickProviderSpec, TickFormatterSpec, RenderSpec; +import 'tick_spec.dart' show TickSpec; + +/// [AxisSpec] specialized for ordinal/non-continuous axes typically for bars. +@immutable +class OrdinalAxisSpec extends AxisSpec { + /// Creates a [AxisSpec] that specialized for ordinal domain charts. + /// + /// [renderSpec] spec used to configure how the ticks and labels + /// actually render. Possible values are [GridlineRendererSpec], + /// [SmallTickRendererSpec] & [NoneRenderSpec]. Make sure that the + /// given to the RenderSpec is of type [String] when using this spec. + /// [tickProviderSpec] spec used to configure what ticks are generated. + /// [tickFormatterSpec] spec used to configure how the tick labels are + /// formatted. + /// [showAxisLine] override to force the axis to draw the axis line. + OrdinalAxisSpec({ + RenderSpec renderSpec, + OrdinalTickProviderSpec tickProviderSpec, + OrdinalTickFormatterSpec tickFormatterSpec, + bool showAxisLine, + }) : super( + renderSpec: renderSpec, + tickProviderSpec: tickProviderSpec, + tickFormatterSpec: tickFormatterSpec, + showAxisLine: showAxisLine); + + @override + bool operator ==(Object other) => + other is OrdinalAxisSpec && super == (other); +} + +abstract class OrdinalTickProviderSpec + extends TickProviderSpec {} + +abstract class OrdinalTickFormatterSpec extends TickFormatterSpec {} + +@immutable +class BasicOrdinalTickProviderSpec implements OrdinalTickProviderSpec { + BasicOrdinalTickProviderSpec(); + + @override + OrdinalTickProvider createTickProvider(ChartContext context) => + new OrdinalTickProvider(); + + @override + bool operator ==(Object other) => other is BasicOrdinalTickProviderSpec; + + @override + int get hashCode => 37; +} + +/// [TickProviderSpec] that allows you to specific the ticks to be used. +@immutable +class StaticOrdinalTickProviderSpec implements OrdinalTickProviderSpec { + final List> tickSpecs; + + StaticOrdinalTickProviderSpec(this.tickSpecs); + + @override + StaticTickProvider createTickProvider( + ChartContext context) => + new StaticTickProvider(tickSpecs); + + @override + bool operator ==(Object other) => + other is StaticOrdinalTickProviderSpec && tickSpecs == other.tickSpecs; + + @override + int get hashCode => tickSpecs.hashCode; +} + +@immutable +class BasicOrdinalTickFormatterSpec implements OrdinalTickFormatterSpec { + BasicOrdinalTickFormatterSpec(); + + @override + OrdinalTickFormatter createTickFormatter(ChartContext context) => + new OrdinalTickFormatter(); + + @override + bool operator ==(Object other) => other is BasicOrdinalTickFormatterSpec; + + @override + int get hashCode => 37; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/spec/tick_spec.dart b/charts_common/lib/src/chart/cartesian/axis/spec/tick_spec.dart new file mode 100644 index 000000000..13d22fdec --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/spec/tick_spec.dart @@ -0,0 +1,28 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'axis_spec.dart' show TextStyleSpec; + +/// Definition for a tick. +/// +/// Used to define a tick that is used by static tick provider. +class TickSpec { + final D value; + final String label; + final TextStyleSpec style; + + TickSpec(this.value, {String label, this.style}) + : label = label ?? value.toString(); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/static_tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/static_tick_provider.dart new file mode 100644 index 000000000..273eb562a --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/static_tick_provider.dart @@ -0,0 +1,77 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import '../../common/chart_context.dart' show ChartContext; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import 'axis.dart' show AxisOrientation; +import 'scale.dart' show MutableScale, Extents; +import 'tick.dart' show Tick; +import 'spec/tick_spec.dart' show TickSpec; +import 'tick_formatter.dart' show TickFormatter; +import 'draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import 'tick_provider.dart' show TickProvider; + +/// A strategy that uses the ticks provided and only assigns positioning. +/// +/// The [TextStyle] is not overridden during tick draw strategy decorateTicks. +/// If it is null, then the default is used. +class StaticTickProvider> + extends TickProvider { + final List> tickSpec; + + StaticTickProvider(this.tickSpec); + + @override + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required S scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }) { + final ticks = >[]; + + for (TickSpec spec in tickSpec) { + if (scale.compareDomainValueToViewport(spec.value) == 0) { + final tick = new Tick( + value: spec.value, + textElement: graphicsFactory.createTextElement(spec.label), + locationPx: scale[spec.value]); + if (spec.style != null) { + tick.textElement.textStyle = graphicsFactory.createTextPaint() + ..fontFamily = spec.style.fontFamily + ..fontSize = spec.style.fontSize + ..color = spec.style.color; + } + ticks.add(tick); + } + } + + // Allow draw strategy to decorate the ticks. + tickDrawStrategy.decorateTicks(ticks); + + return ticks; + } + + @override + bool operator ==(o) => o is StaticTickProvider && tickSpec == o.tickSpec; + + @override + int get hashCode => tickSpec.hashCode; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/tick.dart b/charts_common/lib/src/chart/cartesian/axis/tick.dart new file mode 100644 index 000000000..4948ef8b1 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/tick.dart @@ -0,0 +1,36 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart'; +import '../../../common/text_element.dart'; + +/// A labeled point on an axis. +/// +/// [D] is the type of the value this tick is associated with. +class Tick { + /// The value that this tick represents + final D value; + + /// [TextElement] for this tick. + final TextElement textElement; + + /// Location on the axis where this tick is rendered (in canvas coordinates). + double locationPx; + + /// This tick is being animated out. + bool markedForRemoval; + + Tick({@required this.value, @required this.textElement, this.locationPx}); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/tick_formatter.dart b/charts_common/lib/src/chart/cartesian/axis/tick_formatter.dart new file mode 100644 index 000000000..84ab0a75a --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/tick_formatter.dart @@ -0,0 +1,89 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:intl/intl.dart'; + +// TODO: Break out into separate files. + +/// A strategy used for converting domain values of the ticks into Strings. +/// +/// [D] is the domain type. +abstract class TickFormatter { + const TickFormatter(); + + /// Formats a list of tick values. + List format(List tickValues, Map cache, {num stepSize}); +} + +abstract class SimpleTickFormatterBase implements TickFormatter { + const SimpleTickFormatterBase(); + + @override + List format(List tickValues, Map cache, + {num stepSize}) => + tickValues.map((D value) { + // Try to use the cached formats first. + String formattedString = cache[value]; + if (formattedString == null) { + formattedString = formatValue(value); + cache[value] = formattedString; + } + return formattedString; + }).toList(); + + /// Formats a single tick value. + String formatValue(D value); +} + +/// A strategy that converts tick labels using toString(). +class OrdinalTickFormatter extends SimpleTickFormatterBase { + const OrdinalTickFormatter(); + + @override + String formatValue(String value) => value; + + @override + bool operator ==(o) => o is OrdinalTickFormatter; + + @override + int get hashCode => 31; +} + +/// A strategy for formatting the labels on numeric ticks using [NumberFormat]. +/// +/// The default format is [NumberFormat.decimalPattern]. +class NumericTickFormatter extends SimpleTickFormatterBase { + final NumberFormat numberFormat; + + /// Constructs a new [NumericTickFormatter]. + /// + /// By default the [NumberFormatFactory] will be [defaultDecimal]. + NumericTickFormatter({NumberFormat numberFormat}) + : this.numberFormat = numberFormat ?? new NumberFormat.decimalPattern(); + + factory NumericTickFormatter.compactSimpleCurrency() => + new NumericTickFormatter( + numberFormat: new NumberFormat.compactSimpleCurrency()); + + @override + String formatValue(num value) => numberFormat.format(value); + + @override + bool operator ==(o) => + o is NumericTickFormatter && numberFormat == o.numberFormat; + + @override + int get hashCode => numberFormat.hashCode; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/tick_provider.dart new file mode 100644 index 000000000..c90b5805e --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/tick_provider.dart @@ -0,0 +1,87 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import '../../common/chart_context.dart' show ChartContext; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import 'axis.dart' show AxisOrientation; +import 'scale.dart' show MutableScale, Extents; +import 'tick.dart' show Tick; +import 'tick_formatter.dart' show TickFormatter; +import 'draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; + +/// A strategy for selecting values for axis ticks based on the domain values. +/// +/// [D] is the domain type. +abstract class TickProvider> { + /// Returns a list of ticks in value order that should be displayed. + /// + /// This method should not return null. If no ticks are desired an empty list + /// should be returned. + /// + /// [graphicsFactory] The graphics factory used for text measurement. + /// [scale] The scale of the data. + /// [formatter] The formatter to use for generating tick labels. + /// [axisOrientation] Orientation of this axis ticks. + /// [tickDrawStrategy] Draw strategy for ticks. + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required S scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }); +} + +/// A base tick provider. +abstract class BaseTickProvider> implements TickProvider { + const BaseTickProvider(); + + /// Create ticks from [domainValues]. + List> createTicks( + List domainValues, { + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required S scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + num stepSize, + }) { + final ticks = >[]; + final labels = + formatter.format(domainValues, formatterValueCache, stepSize: stepSize); + + for (var i = 0; i < domainValues.length; i++) { + final value = domainValues[i]; + final tick = new Tick( + value: value, + textElement: graphicsFactory.createTextElement(labels[i]), + locationPx: scale[value]); + + ticks.add(tick); + } + + // Allow draw strategy to decorate the ticks. + tickDrawStrategy.decorateTicks(ticks); + + return ticks; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/auto_adjusting_date_time_tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/time/auto_adjusting_date_time_tick_provider.dart new file mode 100644 index 000000000..81117bae5 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/auto_adjusting_date_time_tick_provider.dart @@ -0,0 +1,146 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../common/chart_context.dart' show ChartContext; +import '../axis.dart' show AxisOrientation; +import '../tick.dart' show Tick; +import '../draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import '../tick_formatter.dart' show TickFormatter; +import '../tick_provider.dart' show TickProvider; +import 'date_time_extents.dart' show DateTimeExtents; +import 'date_time_scale.dart' show DateTimeScale; +import 'day_time_stepper.dart' show DayTimeStepper; +import 'hour_time_stepper.dart' show HourTimeStepper; +import 'minute_time_stepper.dart' show MinuteTimeStepper; +import 'month_time_stepper.dart' show MonthTimeStepper; +import 'year_time_stepper.dart' show YearTimeStepper; +import 'time_range_tick_provider.dart' show TimeRangeTickProvider; +import 'time_range_tick_provider_impl.dart' show TimeRangeTickProviderImpl; + +/// Tick provider for date and time. +/// +/// When determining the ticks for a given domain, the provider will use choose +/// one of the internal tick providers appropriate to the size of the data's +/// domain range. It does this in an attempt to ensure there are at least 3 +/// ticks, before jumping to the next more fine grain provider. The 3 tick +/// minimum is not a hard rule as some of the ticks might be eliminated because +/// of collisions, but the data was within the targeted range. +/// +/// Once a tick provider is chosen the selection of ticks is done by the child +/// tick provider. +class AutoAdjustingDateTimeTickProvider + implements TickProvider { + /// List of tick providers to be selected from. + final List _potentialTickProviders; + + AutoAdjustingDateTimeTickProvider._internal( + List tickProviders) + : _potentialTickProviders = tickProviders; + + /// Creates a default [AutoAdjustingDateTimeTickProvider] for day and time. + factory AutoAdjustingDateTimeTickProvider.createDefault( + DateTimeFactory dateTimeFactory) { + return new AutoAdjustingDateTimeTickProvider._internal([ + createYearTickProvider(dateTimeFactory), + createMonthTickProvider(dateTimeFactory), + createDayTickProvider(dateTimeFactory), + createHourTickProvider(dateTimeFactory), + createMinuteTickProvider(dateTimeFactory) + ]); + } + + /// Creates a default [AutoAdjustingDateTimeTickProvider] for day only. + factory AutoAdjustingDateTimeTickProvider.createWithoutTime( + DateTimeFactory dateTimeFactory) { + return new AutoAdjustingDateTimeTickProvider._internal([ + createYearTickProvider(dateTimeFactory), + createMonthTickProvider(dateTimeFactory), + createDayTickProvider(dateTimeFactory) + ]); + } + + /// Creates [AutoAdjustingDateTimeTickProvider] with custom tick providers. + /// + /// [potentialTickProviders] must have at least one [TimeRangeTickProvider] + /// and this list of tick providers are used in the order they are provided. + factory AutoAdjustingDateTimeTickProvider.createWith( + List potentialTickProviders) { + if (potentialTickProviders == null || potentialTickProviders.isEmpty) { + throw new ArgumentError('At least one TimeRangeTickProvider is required'); + } + + return new AutoAdjustingDateTimeTickProvider._internal( + potentialTickProviders); + } + + /// Generates a list of ticks for the given data which should not collide + /// unless the range is not large enough. + @override + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required DateTimeScale scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }) { + final viewport = scale.viewportDomain; + for (final tickProvider in _potentialTickProviders) { + if (tickProvider.providesSufficientTicksForRange(viewport)) { + return tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: formatterValueCache, + tickDrawStrategy: tickDrawStrategy, + orientation: orientation, + ); + } + } + + return _potentialTickProviders.last.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: formatterValueCache, + tickDrawStrategy: tickDrawStrategy, + orientation: orientation, + ); + } + + static TimeRangeTickProvider createYearTickProvider( + DateTimeFactory dateTimeFactory) => + new TimeRangeTickProviderImpl(new YearTimeStepper(dateTimeFactory)); + static TimeRangeTickProvider createMonthTickProvider( + DateTimeFactory dateTimeFactory) => + new TimeRangeTickProviderImpl(new MonthTimeStepper(dateTimeFactory)); + static TimeRangeTickProvider createDayTickProvider( + DateTimeFactory dateTimeFactory) => + new TimeRangeTickProviderImpl(new DayTimeStepper(dateTimeFactory)); + static TimeRangeTickProvider createHourTickProvider( + DateTimeFactory dateTimeFactory) => + new TimeRangeTickProviderImpl(new HourTimeStepper(dateTimeFactory)); + static TimeRangeTickProvider createMinuteTickProvider( + DateTimeFactory dateTimeFactory) => + new TimeRangeTickProviderImpl(new MinuteTimeStepper(dateTimeFactory)); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/base_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/base_time_stepper.dart new file mode 100644 index 000000000..9f05c8c5e --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/base_time_stepper.dart @@ -0,0 +1,141 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'date_time_extents.dart' show DateTimeExtents; +import 'time_stepper.dart' + show TimeStepper, TimeStepIteratorFactory, TimeStepIterator; +import '../../../../common/date_time_factory.dart'; + +/// A base stepper for operating with DateTimeFactory and time range steps. +abstract class BaseTimeStepper implements TimeStepper { + /// The factory to generate a DateTime object. + /// + /// This is needed because Dart's DateTime does not handle time zone. + /// There is a time zone aware library that we could use that implements the + /// DateTime interface. + final DateTimeFactory dateTimeFactory; + + _TimeStepIteratorFactoryImpl _stepsIterable; + + BaseTimeStepper(this.dateTimeFactory); + + /// Get the step time before or on the given [time] from [tickIncrement]. + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement); + + /// Get the next step time after [time] from [tickIncrement]. + DateTime getNextStepTime(DateTime time, int tickIncrement); + + @override + int getStepCountBetween(DateTimeExtents timeExtent, int tickIncrement) { + checkTickIncrement(tickIncrement); + final min = timeExtent.start; + final max = timeExtent.end; + var time = getStepTimeAfterInclusive(min, tickIncrement); + + var cnt = 0; + while (time.compareTo(max) <= 0) { + cnt++; + time = getNextStepTime(time, tickIncrement); + } + return cnt; + } + + @override + TimeStepIteratorFactory getSteps(DateTimeExtents timeExtent) { + // Keep the steps iterable unless time extent changes, so the same iterator + // can be used and reset for different increments. + if (_stepsIterable == null || _stepsIterable.timeExtent != timeExtent) { + _stepsIterable = new _TimeStepIteratorFactoryImpl(timeExtent, this); + } + return _stepsIterable; + } + + @override + DateTimeExtents updateBoundingSteps(DateTimeExtents timeExtent) { + final stepBefore = getStepTimeBeforeInclusive(timeExtent.start, 1); + final stepAfter = getStepTimeAfterInclusive(timeExtent.end, 1); + + return new DateTimeExtents(start: stepBefore, end: stepAfter); + } + + DateTime getStepTimeAfterInclusive(DateTime time, int tickIncrement) { + final boundedStart = getStepTimeBeforeInclusive(time, tickIncrement); + if (boundedStart == time) { + return boundedStart; + } + return getNextStepTime(boundedStart, tickIncrement); + } +} + +class _TimeStepIteratorImpl implements TimeStepIterator { + final DateTime extentStartTime; + final DateTime extentEndTime; + final BaseTimeStepper stepper; + DateTime _current; + int _tickIncrement = 1; + + _TimeStepIteratorImpl( + this.extentStartTime, this.extentEndTime, this.stepper) { + reset(_tickIncrement); + } + + @override + bool moveNext() { + if (_current == null) { + _current = + stepper.getStepTimeAfterInclusive(extentStartTime, _tickIncrement); + } else { + _current = stepper.getNextStepTime(_current, _tickIncrement); + } + + return _current.compareTo(extentEndTime) <= 0; + } + + @override + DateTime get current => _current; + + @override + TimeStepIterator reset(int tickIncrement) { + checkTickIncrement(tickIncrement); + _tickIncrement = tickIncrement; + _current = null; + return this; + } +} + +class _TimeStepIteratorFactoryImpl extends TimeStepIteratorFactory { + final DateTimeExtents timeExtent; + final _TimeStepIteratorImpl _timeStepIterator; + + _TimeStepIteratorFactoryImpl._internal( + _TimeStepIteratorImpl timeStepIterator, this.timeExtent) + : _timeStepIterator = timeStepIterator; + + factory _TimeStepIteratorFactoryImpl( + DateTimeExtents timeExtent, BaseTimeStepper stepper) { + final startTime = timeExtent.start; + final endTime = timeExtent.end; + return new _TimeStepIteratorFactoryImpl._internal( + new _TimeStepIteratorImpl(startTime, endTime, stepper), timeExtent); + } + + @override + TimeStepIterator get iterator => _timeStepIterator; +} + +void checkTickIncrement(int tickIncrement) { + /// tickIncrement must be greater than 0 + assert(tickIncrement > 0); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/date_time_axis.dart b/charts_common/lib/src/chart/cartesian/axis/time/date_time_axis.dart new file mode 100644 index 000000000..6785609da --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/date_time_axis.dart @@ -0,0 +1,37 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import '../axis.dart' show Axis; +import '../tick_formatter.dart' show TickFormatter; +import '../tick_provider.dart' show TickProvider; +import 'auto_adjusting_date_time_tick_provider.dart' + show AutoAdjustingDateTimeTickProvider; +import 'date_time_extents.dart' show DateTimeExtents; +import 'date_time_scale.dart' show DateTimeScale; +import 'date_time_tick_formatter.dart' show DateTimeTickFormatter; + +class DateTimeAxis extends Axis { + DateTimeAxis(DateTimeFactory dateTimeFactory, + {TickProvider tickProvider, TickFormatter tickFormatter}) + : super( + tickProvider: tickProvider ?? + new AutoAdjustingDateTimeTickProvider.createDefault( + dateTimeFactory), + tickFormatter: + tickFormatter ?? new DateTimeTickFormatter(dateTimeFactory), + scale: new DateTimeScale(dateTimeFactory), + ); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/date_time_extents.dart b/charts_common/lib/src/chart/cartesian/axis/time/date_time_extents.dart new file mode 100644 index 000000000..6db5d47a6 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/date_time_extents.dart @@ -0,0 +1,25 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; + +import '../scale.dart' show Extents; + +class DateTimeExtents extends Extents { + final DateTime start; + final DateTime end; + + DateTimeExtents({@required this.start, @required this.end}); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/date_time_scale.dart b/charts_common/lib/src/chart/cartesian/axis/time/date_time_scale.dart new file mode 100644 index 000000000..742a8941a --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/date_time_scale.dart @@ -0,0 +1,137 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../numeric_extents.dart' show NumericExtents; +import 'date_time_extents.dart' show DateTimeExtents; +import '../scale.dart' + show MutableScale, StepSizeConfig, RangeBandConfig, ScaleOutputExtent; +import '../linear/linear_scale.dart' show LinearScale; +import '../../../../common/date_time_factory.dart' show DateTimeFactory; + +/// [DateTimeScale] is a wrapper for [LinearScale]. +/// [DateTime] values are converted to millisecondsSinceEpoch and passed to the +/// [LinearScale]. +class DateTimeScale extends MutableScale { + final DateTimeFactory dateTimeFactory; + final LinearScale _linearScale; + + DateTimeScale(this.dateTimeFactory) : _linearScale = new LinearScale(); + + DateTimeScale._copy(DateTimeScale other) + : dateTimeFactory = other.dateTimeFactory, + _linearScale = other._linearScale.copy(); + + @override + num operator [](DateTime domainValue) => + _linearScale[domainValue.millisecondsSinceEpoch]; + + @override + DateTime reverse(double pixelLocation) => + dateTimeFactory.createDateTimeFromMilliSecondsSinceEpoch( + _linearScale.reverse(pixelLocation)); + + @override + void resetDomain() { + _linearScale.resetDomain(); + } + + @override + set stepSizeConfig(StepSizeConfig config) { + _linearScale.stepSizeConfig = config; + } + + @override + StepSizeConfig get stepSizeConfig => _linearScale.stepSizeConfig; + + @override + set rangeBandConfig(RangeBandConfig barGroupWidthConfig) { + _linearScale.rangeBandConfig = barGroupWidthConfig; + } + + @override + void setViewportSettings(double viewportScale, double viewportTranslatePx) { + _linearScale.setViewportSettings(viewportScale, viewportTranslatePx); + } + + @override + set range(ScaleOutputExtent extent) { + _linearScale.range = extent; + } + + @override + void addDomain(DateTime domainValue) { + _linearScale.addDomain(domainValue.millisecondsSinceEpoch); + } + + @override + void resetViewportSettings() { + _linearScale.resetViewportSettings(); + } + + @override + DateTimeExtents get viewportDomain { + final extents = _linearScale.viewportDomain; + return new DateTimeExtents( + start: dateTimeFactory + .createDateTimeFromMilliSecondsSinceEpoch(extents.min.toInt()), + end: dateTimeFactory + .createDateTimeFromMilliSecondsSinceEpoch(extents.max.toInt())); + } + + @override + set viewportDomain(DateTimeExtents extents) { + _linearScale.viewportDomain = new NumericExtents( + extents.start.millisecondsSinceEpoch, + extents.end.millisecondsSinceEpoch); + } + + @override + DateTimeScale copy() => new DateTimeScale._copy(this); + + @override + double get viewportTranslatePx => _linearScale.viewportTranslatePx; + + @override + double get viewportScalingFactor => _linearScale.viewportScalingFactor; + + @override + bool isRangeValueWithinViewport(double rangeValue) => + _linearScale.isRangeValueWithinViewport(rangeValue); + + @override + int compareDomainValueToViewport(DateTime domainValue) => _linearScale + .compareDomainValueToViewport(domainValue.millisecondsSinceEpoch); + + @override + double get rangeBand => _linearScale.rangeBand; + + @override + double get stepSize => _linearScale.stepSize; + + @override + RangeBandConfig get rangeBandConfig => _linearScale.rangeBandConfig; + + @override + int get rangeWidth => _linearScale.rangeWidth; + + @override + ScaleOutputExtent get range => _linearScale.range; + + @override + bool canTranslate(DateTime domainValue) => + _linearScale.canTranslate(domainValue.millisecondsSinceEpoch); + + NumericExtents get dataExtent => _linearScale.dataExtent; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/date_time_tick_formatter.dart b/charts_common/lib/src/chart/cartesian/axis/time/date_time_tick_formatter.dart new file mode 100644 index 000000000..6c67fb150 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/date_time_tick_formatter.dart @@ -0,0 +1,220 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import '../tick_formatter.dart' show TickFormatter; +import 'hour_tick_formatter.dart' show HourTickFormatter; +import 'time_tick_formatter.dart' show TimeTickFormatter; +import 'time_tick_formatter_impl.dart' + show CalendarField, TimeTickFormatterImpl; + +/// A [TickFormatter] that formats date/time values based on minimum difference +/// between subsequent ticks. +/// +/// This formatter assumes that the Tick values passed in are sorted in +/// increasing order. +/// +/// This class is setup with a list of formatters that format the input ticks at +/// a given time resolution. The time resolution which will accurately display +/// the difference between 2 subsequent ticks is picked. Each time resolution +/// can be setup with a [TimeTickFormatter], which is used to format ticks as +/// regular or transition ticks based on whether the tick has crossed the time +/// boundary defined in the [TimeTickFormatter]. +class DateTimeTickFormatter implements TickFormatter { + static const int SECOND = 1000; + static const int MINUTE = 60 * SECOND; + static const int HOUR = 60 * MINUTE; + static const int DAY = 24 * HOUR; + + /// Used for the case when there is only one formatter. + static const int ANY = -1; + + final Map _timeFormatters; + + /// Creates a [DateTimeTickFormatter] that works well with time tick provider + /// classes. + /// + /// The default formatter makes assumptions on border cases that time tick + /// providers will still provide ticks that make sense. Example: Tick provider + /// does not provide ticks with 23 hour intervals. For custom tick providers + /// where these assumptions are not correct, please create a custom + /// [TickFormatter]. + factory DateTimeTickFormatter(DateTimeFactory dateTimeFactory, + {Map overrides}) { + final Map map = { + MINUTE: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'mm', + transitionFormat: 'h mm', + transitionField: CalendarField.hourOfDay), + HOUR: new HourTickFormatter( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'h', + transitionFormat: 'MMM d ha', + noonFormat: 'ha'), + 23 * HOUR: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'd', + transitionFormat: 'MMM d', + transitionField: CalendarField.month), + 28 * DAY: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'MMM', + transitionFormat: 'MMM yyyy', + transitionField: CalendarField.year), + 364 * DAY: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'yyyy', + transitionFormat: 'yyyy', + transitionField: CalendarField.year), + }; + + // Allow the user to override some of the defaults. + if (overrides != null) { + map.addAll(overrides); + } + + return new DateTimeTickFormatter._internal(map); + } + + /// Creates a [DateTimeTickFormatter] without the time component. + factory DateTimeTickFormatter.withoutTime(DateTimeFactory dateTimeFactory) { + return new DateTimeTickFormatter._internal({ + 23 * HOUR: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'd', + transitionFormat: 'MMM d', + transitionField: CalendarField.month), + 28 * DAY: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'MMM', + transitionFormat: 'MMM yyyy', + transitionField: CalendarField.year), + 365 * DAY: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: 'yyyy', + transitionFormat: 'yyyy', + transitionField: CalendarField.year), + }); + } + + /// Creates a [DateTimeTickFormatter] that formats all ticks the same. + /// + /// Only use this formatter for data with fixed intervals, otherwise use the + /// default, or build from scratch. + /// + /// [pattern] The format for all ticks. + factory DateTimeTickFormatter.uniform( + DateTimeFactory dateTimeFactory, String pattern) { + return new DateTimeTickFormatter._internal({ + ANY: new TimeTickFormatterImpl( + dateTimeFactory: dateTimeFactory, + simpleFormat: pattern, + transitionFormat: pattern), + }); + } + + /// Creates a [DateTimeTickFormatter] that formats ticks with [formatters]. + /// + /// The formatters are expected to be provided with keys in increasing order. + factory DateTimeTickFormatter.withFormatters( + Map formatters) { + // Formatters must be non empty. + if (formatters == null || formatters.isEmpty) { + throw new ArgumentError('At least one TimeTickFormatter is required.'); + } + + return new DateTimeTickFormatter._internal(formatters); + } + + DateTimeTickFormatter._internal(this._timeFormatters) { + _checkPositiveAndSorted(_timeFormatters.keys); + } + + @override + List format(List tickValues, Map cache, + {@required num stepSize}) { + final tickLabels = []; + if (tickValues.isEmpty) { + return tickLabels; + } + + // Find the formatter that is the largest interval that has enough + // resolution to describe the difference between ticks. If no such formatter + // exists pick the highest res one. + var formatter = _timeFormatters[_timeFormatters.keys.first]; + var formatterFound = false; + if (_timeFormatters.keys.first == ANY) { + formatterFound = true; + } else { + int minTimeBetweenTicks = stepSize.toInt(); + + // TODO: Skip the formatter if the formatter's step size is + // smaller than the minimum step size of the data. + + var keys = _timeFormatters.keys.iterator; + while (keys.moveNext() && !formatterFound) { + if (keys.current > minTimeBetweenTicks) { + formatterFound = true; + } else { + formatter = _timeFormatters[keys.current]; + } + } + } + + // Format the ticks. + final tickValuesIt = tickValues.iterator; + + var tickValue = (tickValuesIt..moveNext()).current; + var prevTickValue = tickValue; + tickLabels.add(formatter.formatFirstTick(tickValue)); + + while (tickValuesIt.moveNext()) { + tickValue = tickValuesIt.current; + if (formatter.isTransition(tickValue, prevTickValue)) { + tickLabels.add(formatter.formatTransitionTick(tickValue)); + } else { + tickLabels.add(formatter.formatSimpleTick(tickValue)); + } + prevTickValue = tickValue; + } + + return tickLabels; + } + + static void _checkPositiveAndSorted(Iterable values) { + final valuesIterator = values.iterator; + var prev = (valuesIterator..moveNext()).current; + var isSorted = true; + + // Only need to check the first value, because the values after are expected + // to be greater. + if (prev <= 0) { + throw new ArgumentError('Formatter keys must be positive'); + } + + while (valuesIterator.moveNext() && isSorted) { + isSorted = prev < valuesIterator.current; + prev = valuesIterator.current; + } + + if (!isSorted) { + throw new ArgumentError( + 'Formatters must be sorted with keys in increasing order'); + } + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/day_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/day_time_stepper.dart new file mode 100644 index 000000000..78be39184 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/day_time_stepper.dart @@ -0,0 +1,81 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'base_time_stepper.dart' show BaseTimeStepper; + +/// Day stepper. +class DayTimeStepper extends BaseTimeStepper { + // TODO: Remove the 14 day increment if we add week stepper. + static const _defaultIncrements = const [1, 2, 3, 7, 14]; + static const _hoursInDay = 24; + + final List _allowedTickIncrements; + + DayTimeStepper._internal( + DateTimeFactory dateTimeFactory, List increments) + : _allowedTickIncrements = increments, + super(dateTimeFactory); + + factory DayTimeStepper(DateTimeFactory dateTimeFactory, + {List allowedTickIncrements}) { + // Set the default increments if null. + allowedTickIncrements ??= _defaultIncrements; + + // Must have at least one increment option. + assert(allowedTickIncrements.length > 0); + // All increments must be > 0. + assert(allowedTickIncrements.any((increment) => increment <= 0) == false); + + return new DayTimeStepper._internal(dateTimeFactory, allowedTickIncrements); + } + + @override + int get typicalStepSizeMs => _hoursInDay * 3600 * 1000; + + @override + List get allowedTickIncrements => _allowedTickIncrements; + + /// Get the step time before or on the given [time] from [tickIncrement]. + /// + /// Increments are based off the beginning of the month. + /// Ex. 5 day increments in a month is 1,6,11,16,21,26,31 + /// Ex. Time is Aug 20, increment is 1 day. Returns Aug 20. + /// Ex. Time is Aug 20, increment is 2 days. Returns Aug 19 because 2 day + /// increments in a month is 1,3,5,7,9,11,13,15,17,19,21.... + @override + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement) { + final dayRemainder = (time.day - 1) % tickIncrement; + // Subtract an extra hour in case stepping through a daylight saving change. + final dayBefore = dayRemainder > 0 + ? time.subtract(new Duration(hours: (_hoursInDay * dayRemainder) - 1)) + : time; + // Explicitly leaving off hours and beyond to truncate to start of day. + final stepBefore = dateTimeFactory.createDateTime( + dayBefore.year, dayBefore.month, dayBefore.day); + + return stepBefore; + } + + @override + DateTime getNextStepTime(DateTime time, int tickIncrement) { + // Add an extra hour in case stepping through a daylight saving change. + final stepAfter = + time.add(new Duration(hours: (_hoursInDay * tickIncrement) + 1)); + // Explicitly leaving off hours and beyond to truncate to start of day. + return dateTimeFactory.createDateTime( + stepAfter.year, stepAfter.month, stepAfter.day); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/hour_tick_formatter.dart b/charts_common/lib/src/chart/cartesian/axis/time/hour_tick_formatter.dart new file mode 100644 index 000000000..ef262c3dc --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/hour_tick_formatter.dart @@ -0,0 +1,45 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:intl/intl.dart' show DateFormat; +import 'package:meta/meta.dart' show required; +import '../../../../common/date_time_factory.dart'; +import 'time_tick_formatter_impl.dart' + show CalendarField, TimeTickFormatterImpl; + +/// Hour specific tick formatter which will format noon differently. +class HourTickFormatter extends TimeTickFormatterImpl { + DateFormat _noonFormat; + + HourTickFormatter( + {@required DateTimeFactory dateTimeFactory, + @required String simpleFormat, + @required String transitionFormat, + @required String noonFormat}) + : super( + dateTimeFactory: dateTimeFactory, + simpleFormat: simpleFormat, + transitionFormat: transitionFormat, + transitionField: CalendarField.date) { + _noonFormat = dateTimeFactory.createDateFormat(noonFormat); + } + + @override + String formatSimpleTick(DateTime date) { + return (date.hour == 12) + ? _noonFormat.format(date) + : super.formatSimpleTick(date); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/hour_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/hour_time_stepper.dart new file mode 100644 index 000000000..f72b8f387 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/hour_time_stepper.dart @@ -0,0 +1,88 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'base_time_stepper.dart' show BaseTimeStepper; + +/// Hour stepper. +class HourTimeStepper extends BaseTimeStepper { + static const _defaultIncrements = const [1, 2, 3, 4, 6, 12, 24]; + static const _hoursInDay = 24; + static const _millisecondsInHour = 3600 * 1000; + + final List _allowedTickIncrements; + + HourTimeStepper._internal( + DateTimeFactory dateTimeFactory, List increments) + : _allowedTickIncrements = increments, + super(dateTimeFactory); + + factory HourTimeStepper(DateTimeFactory dateTimeFactory, + {List allowedTickIncrements}) { + // Set the default increments if null. + allowedTickIncrements ??= _defaultIncrements; + + // Must have at least one increment option. + assert(allowedTickIncrements.length > 0); + // All increments must be between 1 and 24 inclusive. + assert(allowedTickIncrements + .any((increment) => increment <= 0 || increment > 24) == + false); + + return new HourTimeStepper._internal( + dateTimeFactory, allowedTickIncrements); + } + + @override + int get typicalStepSizeMs => _millisecondsInHour; + + @override + List get allowedTickIncrements => _allowedTickIncrements; + + /// Get the step time before or on the given [time] from [tickIncrement]. + /// + /// Guarantee a step at the start of the next day. + /// Ex. Time is Aug 20 10 AM, increment is 1 hour. Returns 10 AM. + /// Ex. Time is Aug 20 6 AM, increment is 4 hours. Returns 4 AM. + @override + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement) { + final nextDay = dateTimeFactory + .createDateTime(time.year, time.month, time.day) + .add(new Duration(hours: _hoursInDay + 1)); + final nextDayStart = dateTimeFactory.createDateTime( + nextDay.year, nextDay.month, nextDay.day); + + final hoursToNextDay = + ((nextDayStart.millisecondsSinceEpoch - time.millisecondsSinceEpoch) / + _millisecondsInHour) + .ceil(); + + final hoursRemainder = hoursToNextDay % tickIncrement; + final rewindHours = + hoursRemainder == 0 ? 0 : tickIncrement - hoursRemainder; + final stepBefore = dateTimeFactory.createDateTime( + time.year, time.month, time.day, time.hour - rewindHours); + + return stepBefore; + } + + /// Get next step time. + /// + /// [time] is expected to be a [DateTime] with the hour at start of the hour. + @override + DateTime getNextStepTime(DateTime time, int tickIncrement) { + return time.add(new Duration(hours: tickIncrement)); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/minute_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/minute_time_stepper.dart new file mode 100644 index 000000000..da362a2bd --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/minute_time_stepper.dart @@ -0,0 +1,78 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'base_time_stepper.dart'; + +/// Minute stepper where ticks generated aligns with the hour. +class MinuteTimeStepper extends BaseTimeStepper { + static const _defaultIncrements = const [5, 10, 15, 20, 30]; + static const _millisecondsInMinute = 60 * 1000; + + final List _allowedTickIncrements; + + MinuteTimeStepper._internal( + DateTimeFactory dateTimeFactory, List increments) + : _allowedTickIncrements = increments, + super(dateTimeFactory); + + factory MinuteTimeStepper(DateTimeFactory dateTimeFactory, + {List allowedTickIncrements}) { + // Set the default increments if null. + allowedTickIncrements ??= _defaultIncrements; + + // Must have at least one increment + assert(allowedTickIncrements.isNotEmpty); + // Increment must be between 1 and 60 inclusive. + assert(allowedTickIncrements + .any((increment) => increment <= 0 || increment > 60) == + false); + + return new MinuteTimeStepper._internal( + dateTimeFactory, allowedTickIncrements); + } + + @override + int get typicalStepSizeMs => _millisecondsInMinute; + + List get allowedTickIncrements => _allowedTickIncrements; + + /// Picks a tick start time that guarantees the start of the hour is included. + /// + /// Ex. Time is 3:46, increments is 5 minutes, step before is 3:45, because + /// we can guarantee a step at 4:00. + @override + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement) { + final nextHourStart = time.millisecondsSinceEpoch + + (60 - time.minute) * _millisecondsInMinute; + + final minutesToNextHour = + ((nextHourStart - time.millisecondsSinceEpoch) / _millisecondsInMinute) + .ceil(); + + final minRemainder = minutesToNextHour % tickIncrement; + final rewindMinutes = minRemainder == 0 ? 0 : tickIncrement - minRemainder; + + final stepBefore = dateTimeFactory.createDateTimeFromMilliSecondsSinceEpoch( + time.millisecondsSinceEpoch - rewindMinutes * _millisecondsInMinute); + + return stepBefore; + } + + @override + DateTime getNextStepTime(DateTime time, int tickIncrement) { + return time.add(new Duration(minutes: tickIncrement)); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/month_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/month_time_stepper.dart new file mode 100644 index 000000000..e34a8e43e --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/month_time_stepper.dart @@ -0,0 +1,72 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'base_time_stepper.dart' show BaseTimeStepper; + +/// Month stepper. +class MonthTimeStepper extends BaseTimeStepper { + static const _defaultIncrements = const [1, 2, 3, 4, 6, 12]; + + final List _allowedTickIncrements; + + MonthTimeStepper._internal( + DateTimeFactory dateTimeFactory, List increments) + : _allowedTickIncrements = increments, + super(dateTimeFactory); + + factory MonthTimeStepper(DateTimeFactory dateTimeFactory, + {List allowedTickIncrements}) { + // Set the default increments if null. + allowedTickIncrements ??= _defaultIncrements; + + // Must have at least one increment option. + assert(allowedTickIncrements.length > 0); + // All increments must be > 0. + assert(allowedTickIncrements.any((increment) => increment <= 0) == false); + + return new MonthTimeStepper._internal( + dateTimeFactory, allowedTickIncrements); + } + + @override + int get typicalStepSizeMs => 30 * 24 * 3600 * 1000; + + @override + List get allowedTickIncrements => _allowedTickIncrements; + + /// Guarantee a step ending in the last month of the year. + /// + /// If date is 2017 Oct and increments is 6, the step before is 2017 June. + @override + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement) { + final monthRemainder = time.month % tickIncrement; + final newMonth = (time.month - monthRemainder) % DateTime.MONTHS_PER_YEAR; + final newYear = + time.year - (monthRemainder / DateTime.MONTHS_PER_YEAR).floor(); + + return dateTimeFactory.createDateTime(newYear, newMonth); + } + + @override + DateTime getNextStepTime(DateTime time, int tickIncrement) { + final incrementedMonth = time.month + tickIncrement; + final newMonth = incrementedMonth % DateTime.MONTHS_PER_YEAR; + final newYear = + time.year + (incrementedMonth / DateTime.MONTHS_PER_YEAR).floor(); + + return dateTimeFactory.createDateTime(newYear, newMonth); + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider.dart b/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider.dart new file mode 100644 index 000000000..db31f2f60 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider.dart @@ -0,0 +1,28 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../tick_provider.dart' show BaseTickProvider; +import '../time/date_time_extents.dart' show DateTimeExtents; +import '../time/date_time_scale.dart' show DateTimeScale; + +/// Provides ticks for a particular time unit. +/// +/// Used by [AutoAdjustingDateTimeTickProvider]. +abstract class TimeRangeTickProvider + extends BaseTickProvider { + /// Returns if this tick provider will produce a sufficient number of ticks + /// for [domainExtents]. + bool providesSufficientTicksForRange(DateTimeExtents domainExtents); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider_impl.dart b/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider_impl.dart new file mode 100644 index 000000000..aa53bcb0c --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/time_range_tick_provider_impl.dart @@ -0,0 +1,93 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import '../../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../common/chart_context.dart' show ChartContext; +import '../axis.dart' show AxisOrientation; +import '../tick.dart' show Tick; +import '../draw_strategy/tick_draw_strategy.dart' show TickDrawStrategy; +import '../tick_formatter.dart' show TickFormatter; +import 'date_time_scale.dart' show DateTimeScale; +import 'date_time_extents.dart' show DateTimeExtents; +import 'time_range_tick_provider.dart' show TimeRangeTickProvider; +import 'time_stepper.dart' show TimeStepper; + +// Contains all the common code for the time range tick providers. +class TimeRangeTickProviderImpl extends TimeRangeTickProvider { + final requiredMinimumTicks; + final TimeStepper timeStepper; + + TimeRangeTickProviderImpl(this.timeStepper, {this.requiredMinimumTicks: 3}); + + @override + bool providesSufficientTicksForRange(DateTimeExtents domainExtents) { + final cnt = timeStepper.getStepCountBetween(domainExtents, 1); + return cnt >= requiredMinimumTicks; + } + + @override + List> getTicks({ + @required ChartContext context, + @required GraphicsFactory graphicsFactory, + @required DateTimeScale scale, + @required TickFormatter formatter, + @required Map formatterValueCache, + @required TickDrawStrategy tickDrawStrategy, + @required AxisOrientation orientation, + bool viewportExtensionEnabled: false, + }) { + List> currentTicks; + final tickValues = []; + final timeStepIt = timeStepper.getSteps(scale.viewportDomain).iterator; + + // Try different tickIncrements and choose the first that has no collisions. + // If none exist use the last one which should have the fewest ticks and + // hope that the renderer will resolve collisions. + final allowedTickIncrements = timeStepper.allowedTickIncrements; + + for (int i = 0; i < allowedTickIncrements.length; i++) { + // Create tick values with a specified increment. + final tickIncrement = allowedTickIncrements[i]; + tickValues.clear(); + timeStepIt.reset(tickIncrement); + while (timeStepIt.moveNext()) { + tickValues.add(timeStepIt.current); + } + + // Create ticks + currentTicks = createTicks(tickValues, + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: formatterValueCache, + tickDrawStrategy: tickDrawStrategy, + stepSize: timeStepper.typicalStepSizeMs * tickIncrement); + + // Request collision check from draw strategy. + final collisionReport = + tickDrawStrategy.collides(currentTicks, orientation); + + if (!collisionReport.ticksCollide) { + // Return the first non colliding ticks. + return currentTicks; + } + } + + // If all ticks collide, return the last generated ticks. + return currentTicks; + } +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/time_stepper.dart new file mode 100644 index 000000000..480f7284f --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/time_stepper.dart @@ -0,0 +1,60 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'date_time_extents.dart' show DateTimeExtents; + +/// Represents the step/tick information for the given time range. +abstract class TimeStepper { + /// Get new bounding extents to the ticks that would contain the given + /// timeExtents. + DateTimeExtents updateBoundingSteps(DateTimeExtents timeExtents); + + /// Returns the number steps/ticks are between the given extents inclusive. + /// + /// Does not extend the extents to the bounding ticks. + int getStepCountBetween(DateTimeExtents timeExtents, int tickIncrement); + + /// Generates an Iterable for iterating over the time steps bounded by the + /// given timeExtents. The desired tickIncrement can be set on the returned + /// [TimeStepIteratorFactory]. + TimeStepIteratorFactory getSteps(DateTimeExtents timeExtents); + + /// Returns the typical stepSize for this stepper assuming increment by 1. + int get typicalStepSizeMs; + + /// An ordered list of step increments that makes sense given the step. + /// + /// Example: hours may increment by 1, 2, 3, 4, 6, 12. It doesn't make sense + /// to increment hours by 7. + List get allowedTickIncrements; +} + +/// Iterator with a reset function that can be used multiple times to avoid +/// object instantiation during the Android layout/draw phases. +abstract class TimeStepIterator extends Iterator { + /// Reset the iterator and set the tickIncrement to the specified value. + /// + /// This method is provided so that the same iterator instance can be used for + /// different tick increments, avoiding object allocation during Android + /// layout/draw phases. + TimeStepIterator reset(int tickIncrement); +} + +/// Factory that creates TimeStepIterator with the set tickIncrement value. +abstract class TimeStepIteratorFactory extends Iterable { + /// Get iterator and optionally set the tickIncrement. + @override + TimeStepIterator get iterator; +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter.dart b/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter.dart new file mode 100644 index 000000000..cb13e486d --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter.dart @@ -0,0 +1,31 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Formatter of [DateTime] ticks +abstract class TimeTickFormatter { + /// Format for tick that is the first in a set of ticks. + String formatFirstTick(DateTime date); + + /// Format for a 'simple' tick. + /// + /// Ex. Not a first tick or transition tick. + String formatSimpleTick(DateTime date); + + /// Format for a transitional tick. + String formatTransitionTick(DateTime date); + + /// Returns true if tick is a transitional tick. + bool isTransition(DateTime tickValue, DateTime prevTickValue); +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter_impl.dart b/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter_impl.dart new file mode 100644 index 000000000..b5696d94e --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/time_tick_formatter_impl.dart @@ -0,0 +1,100 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:intl/intl.dart' show DateFormat; +import 'package:meta/meta.dart' show required; +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'time_tick_formatter.dart' show TimeTickFormatter; + +/// Formatter that can format simple and transition time ticks differently. +class TimeTickFormatterImpl implements TimeTickFormatter { + DateFormat _simpleFormat; + DateFormat _transitionFormat; + final CalendarField transitionField; + + /// Create time tick formatter. + /// + /// [dateTimeFactory] factory to use to generate the [DateFormat]. + /// [simpleFormat] format to use for most ticks. + /// [transitionFormat] format to use when the time unit transitions. + /// For example showing the month with the date for Jan 1. + /// [transitionField] the calendar field that indicates transition. + TimeTickFormatterImpl( + {@required DateTimeFactory dateTimeFactory, + @required String simpleFormat, + @required String transitionFormat, + this.transitionField}) { + _simpleFormat = dateTimeFactory.createDateFormat(simpleFormat); + _transitionFormat = dateTimeFactory.createDateFormat(transitionFormat); + } + + @override + String formatFirstTick(DateTime date) => _transitionFormat.format(date); + + @override + String formatSimpleTick(DateTime date) => _simpleFormat.format(date); + + @override + String formatTransitionTick(DateTime date) => _transitionFormat.format(date); + + @override + bool isTransition(DateTime tickValue, DateTime prevTickValue) { + // Transition is always false if no transition field is specified. + if (transitionField == null) { + return false; + } + final prevTransitionFieldValue = + getCalendarField(prevTickValue, transitionField); + final transitionFieldValue = getCalendarField(tickValue, transitionField); + return prevTransitionFieldValue != transitionFieldValue; + } + + /// Gets the calendar field for [dateTime]. + int getCalendarField(DateTime dateTime, CalendarField field) { + int value; + + switch (field) { + case CalendarField.year: + value = dateTime.year; + break; + case CalendarField.month: + value = dateTime.month; + break; + case CalendarField.date: + value = dateTime.day; + break; + case CalendarField.hourOfDay: + value = dateTime.hour; + break; + case CalendarField.minute: + value = dateTime.minute; + break; + case CalendarField.second: + value = dateTime.second; + break; + } + + return value; + } +} + +enum CalendarField { + year, + month, + date, + hourOfDay, + minute, + second, +} diff --git a/charts_common/lib/src/chart/cartesian/axis/time/year_time_stepper.dart b/charts_common/lib/src/chart/cartesian/axis/time/year_time_stepper.dart new file mode 100644 index 000000000..6fcda2320 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/axis/time/year_time_stepper.dart @@ -0,0 +1,63 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/date_time_factory.dart' show DateTimeFactory; +import 'base_time_stepper.dart' show BaseTimeStepper; + +/// Year stepper. +class YearTimeStepper extends BaseTimeStepper { + static const _defaultIncrements = const [1, 2, 5, 10, 50, 100, 500, 1000]; + + final List _allowedTickIncrements; + + YearTimeStepper._internal( + DateTimeFactory dateTimeFactory, List increments) + : _allowedTickIncrements = increments, + super(dateTimeFactory); + + factory YearTimeStepper(DateTimeFactory dateTimeFactory, + {List allowedTickIncrements}) { + // Set the default increments if null. + allowedTickIncrements ??= _defaultIncrements; + + // Must have at least one increment option. + assert(allowedTickIncrements.length > 0); + // All increments must be > 0. + assert(allowedTickIncrements.any((increment) => increment <= 0) == false); + + return new YearTimeStepper._internal( + dateTimeFactory, allowedTickIncrements); + } + + @override + int get typicalStepSizeMs => 365 * 24 * 3600 * 1000; + + @override + List get allowedTickIncrements => _allowedTickIncrements; + + /// Guarantees the increment is a factor of the tick value. + /// + /// Example: 2017, tick increment of 10, step before is 2010. + @override + DateTime getStepTimeBeforeInclusive(DateTime time, int tickIncrement) { + final yearRemainder = time.year % tickIncrement; + return dateTimeFactory.createDateTime(time.year - yearRemainder); + } + + @override + DateTime getNextStepTime(DateTime time, int tickIncrement) { + return dateTimeFactory.createDateTime(time.year + tickIncrement); + } +} diff --git a/charts_common/lib/src/chart/cartesian/cartesian_chart.dart b/charts_common/lib/src/chart/cartesian/cartesian_chart.dart new file mode 100644 index 000000000..c9c222902 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/cartesian_chart.dart @@ -0,0 +1,231 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'axis/axis.dart' + show + Axis, + AxisOrientation, + OrdinalAxis, + NumericAxis, + domainAxisKey, + measureAxisIdKey, + measureAxisKey; +import 'axis/spec/axis_spec.dart' show AxisSpec; +import 'axis/draw_strategy/small_tick_draw_strategy.dart' + show SmallTickRendererSpec; +import 'axis/draw_strategy/gridline_draw_strategy.dart' + show GridlineRendererSpec; +import '../bar/bar_renderer.dart' show BarRenderer; +import '../common/base_chart.dart' show BaseChart; +import '../common/chart_context.dart' show ChartContext; +import '../common/processed_series.dart' show MutableSeries; +import '../common/series_renderer.dart' show SeriesRenderer; +import '../layout/layout_config.dart' show LayoutConfig, MarginSpec; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../../common/rtl_spec.dart' show AxisPosition; +import '../../data/series.dart' show Series; + +class NumericCartesianChart extends CartesianChart { + final NumericAxis _domainAxis; + + NumericCartesianChart({bool vertical, LayoutConfig layoutConfig}) + : _domainAxis = new NumericAxis(), + super(vertical: vertical, layoutConfig: layoutConfig); + + void init(ChartContext context, GraphicsFactory graphicsFactory) { + super.init(context, graphicsFactory); + _domainAxis.context = context; + _domainAxis.tickDrawStrategy = new SmallTickRendererSpec() + .createDrawStrategy(context, graphicsFactory); + addView(_domainAxis); + } + + @override + Axis get domainAxis => _domainAxis; +} + +class OrdinalCartesianChart extends CartesianChart { + final OrdinalAxis _domainAxis; + + OrdinalCartesianChart({bool vertical, LayoutConfig layoutConfig}) + : _domainAxis = new OrdinalAxis(), + super(vertical: vertical, layoutConfig: layoutConfig); + + void init(ChartContext context, GraphicsFactory graphicsFactory) { + super.init(context, graphicsFactory); + _domainAxis.context = context; + _domainAxis.tickDrawStrategy = new SmallTickRendererSpec() + .createDrawStrategy(context, graphicsFactory); + addView(_domainAxis); + } + + @override + Axis get domainAxis => _domainAxis; +} + +abstract class CartesianChart extends BaseChart { + static final _defaultLayoutConfig = new LayoutConfig( + topSpec: new MarginSpec.fromPixel(minPixel: 20), + bottomSpec: new MarginSpec.fromPixel(minPixel: 20), + leftSpec: new MarginSpec.fromPixel(minPixel: 20), + rightSpec: new MarginSpec.fromPixel(minPixel: 20), + ); + + bool vertical; + final _primaryMeasureAxis = new NumericAxis(); + final _secondaryMeasureAxis = new NumericAxis(); + + bool _usePrimaryMeasureAxis = false; + bool _useSecondaryMeasureAxis = false; + + CartesianChart({bool vertical, LayoutConfig layoutConfig}) + : vertical = vertical ?? true, + super(layoutConfig: layoutConfig ?? _defaultLayoutConfig); + + void init(ChartContext context, GraphicsFactory graphicsFactory) { + super.init(context, graphicsFactory); + + _primaryMeasureAxis.context = context; + _primaryMeasureAxis.tickDrawStrategy = new GridlineRendererSpec() + .createDrawStrategy(context, graphicsFactory); + _secondaryMeasureAxis.context = context; + _secondaryMeasureAxis.tickDrawStrategy = new GridlineRendererSpec() + .createDrawStrategy(context, graphicsFactory); + } + + Axis get domainAxis; + + set domainAxisSpec(AxisSpec axisSpec) => + axisSpec.configure(domainAxis, context, graphicsFactory); + + Axis getMeasureAxis(String axisId) => axisId == Axis.secondaryMeasureAxisId + ? _secondaryMeasureAxis + : _primaryMeasureAxis; + + set primaryMeasureAxisSpec(AxisSpec axisSpec) => + axisSpec.configure(_primaryMeasureAxis, context, graphicsFactory); + + set secondaryMeasureAxisSpec(AxisSpec axisSpec) => + axisSpec.configure(_secondaryMeasureAxis, context, graphicsFactory); + + @override + MutableSeries makeSeries(Series series) { + MutableSeries s = super.makeSeries(series); + + s.measureOffsetFn ??= (_, __) => 0; + + // Setup the Axes + s.setAttr(domainAxisKey, domainAxis); + s.setAttr( + measureAxisKey, getMeasureAxis(series.getAttribute(measureAxisIdKey))); + + return s; + } + + @override + SeriesRenderer makeDefaultRenderer() { + return new BarRenderer()..rendererId = SeriesRenderer.defaultRendererId; + } + + @override + Map>> preprocessSeries( + List> seriesList) { + var rendererToSeriesList = super.preprocessSeries(seriesList); + + // Check if primary or secondary measure axis is being used. + for (final series in seriesList) { + final measureAxisId = series.getAttr(measureAxisIdKey); + _usePrimaryMeasureAxis = _usePrimaryMeasureAxis || + (measureAxisId == null || measureAxisId == Axis.primaryMeasureAxisId); + _useSecondaryMeasureAxis = _useSecondaryMeasureAxis || + (measureAxisId == Axis.secondaryMeasureAxisId); + } + + // Add or remove the primary axis view. + if (_usePrimaryMeasureAxis) { + addView(_primaryMeasureAxis); + } else { + removeView(_primaryMeasureAxis); + } + + // Add or remove the secondary axis view. + if (_useSecondaryMeasureAxis) { + addView(_secondaryMeasureAxis); + } else { + removeView(_secondaryMeasureAxis); + } + + // Reset stale values from previous draw cycles. + domainAxis.resetDomains(); + _primaryMeasureAxis.resetDomains(); + _secondaryMeasureAxis.resetDomains(); + + final reverseAxisPosition = context != null && + context.rtl && + context.rtlSpec.axisPosition == AxisPosition.reversed; + + if (vertical) { + domainAxis + ..axisOrientation = AxisOrientation.bottom + ..reverseOutputRange = reverseAxisPosition; + _primaryMeasureAxis.axisOrientation = + reverseAxisPosition ? AxisOrientation.right : AxisOrientation.left; + _secondaryMeasureAxis.axisOrientation = + reverseAxisPosition ? AxisOrientation.left : AxisOrientation.right; + } else { + domainAxis.axisOrientation = + reverseAxisPosition ? AxisOrientation.right : AxisOrientation.left; + _primaryMeasureAxis + ..axisOrientation = AxisOrientation.bottom + ..reverseOutputRange = reverseAxisPosition; + _secondaryMeasureAxis + ..axisOrientation = AxisOrientation.top + ..reverseOutputRange = reverseAxisPosition; + } + + // Have each renderer configure the axes with their domain and measure + // values. + rendererToSeriesList + .forEach((String rendererId, List> seriesList) { + getSeriesRenderer(rendererId).configureDomainAxes(seriesList); + getSeriesRenderer(rendererId).configureMeasureAxes(seriesList); + }); + + return rendererToSeriesList; + } + + @override + void onSkipLayout() { + // Update ticks only when skipping layout. + domainAxis.updateTicks(); + + if (_usePrimaryMeasureAxis) { + _primaryMeasureAxis.updateTicks(); + } + if (_useSecondaryMeasureAxis) { + _secondaryMeasureAxis.updateTicks(); + } + + super.onSkipLayout(); + } + + @override + void onPostLayout( + Map>> rendererToSeriesList) { + fireOnAxisConfigured(); + + super.onPostLayout(rendererToSeriesList); + } +} diff --git a/charts_common/lib/src/chart/cartesian/cartesian_renderer.dart b/charts_common/lib/src/chart/cartesian/cartesian_renderer.dart new file mode 100644 index 000000000..af533f328 --- /dev/null +++ b/charts_common/lib/src/chart/cartesian/cartesian_renderer.dart @@ -0,0 +1,212 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart'; + +import 'axis/axis.dart' show Axis, domainAxisKey, measureAxisKey; +import 'cartesian_chart.dart' show CartesianChart; +import '../common/base_chart.dart' show BaseChart; +import '../common/series_renderer.dart' show BaseSeriesRenderer, SeriesRenderer; +import '../common/processed_series.dart' show MutableSeries; +import '../../data/series.dart' show AccessorFn; + +abstract class CartesianRenderer extends SeriesRenderer { + void configureDomainAxes(List> seriesList); + void configureMeasureAxes(List> seriesList); +} + +abstract class BaseCartesianRenderer extends BaseSeriesRenderer + implements CartesianRenderer { + bool _renderingVertically = true; + + BaseCartesianRenderer( + {@required String rendererId, @required int layoutPositionOrder}) + : super(rendererId: rendererId, layoutPositionOrder: layoutPositionOrder); + + @override + void onAttach(BaseChart chart) { + super.onAttach(chart); + _renderingVertically = (chart as CartesianChart).vertical; + } + + bool get renderingVertically => _renderingVertically; + + @override + void configureDomainAxes(List> seriesList) { + seriesList.forEach((MutableSeries series) { + var domainAxis = series.getAttr(domainAxisKey); + var domainFn = series.domainFn; + + if (domainAxis == null) { + return; + } + + if (renderingVertically) { + for (int i = 0; i < series.data.length; i++) { + domainAxis.addDomainValue(domainFn(series.data[i], i)); + } + } else { + // When rendering horizontally, domains are displayed from top to bottom + // in order to match visual display in legend. + for (int i = series.data.length - 1; i >= 0; i--) { + domainAxis.addDomainValue(domainFn(series.data[i], i)); + } + } + }); + } + + @override + void configureMeasureAxes(List> seriesList) { + seriesList.forEach((MutableSeries series) { + var domainAxis = series.getAttr(domainAxisKey); + var domainFn = series.domainFn; + + if (domainAxis == null) { + return; + } + + var measureAxis = series.getAttr(measureAxisKey); + if (measureAxis == null) { + return; + } + + // Only add the measure values for datum who's domain is within the + // domainAxis viewport. + int startIndex = + findNearestViewportStart(domainAxis, domainFn, series.data); + int endIndex = findNearestViewportEnd(domainAxis, domainFn, series.data); + + addMeasureValuesFor(series, measureAxis, startIndex, endIndex); + }); + } + + void addMeasureValuesFor(MutableSeries series, Axis measureAxis, + int startIndex, int endIndex) { + for (int i = startIndex; i <= endIndex; i++) { + final measure = series.measureFn(series.data[i], i); + + if (measure != null) { + measureAxis.addDomainValue(series.measureFn(series.data[i], i) + + series.measureOffsetFn(series.data[i], i)); + } + } + } + + @visibleForTesting + int findNearestViewportStart( + Axis domainAxis, AccessorFn domainFn, List data) { + // Quick optimization for full viewport (likely). + if (domainAxis.compareDomainValueToViewport(domainFn(data[0], 0)) == 0) { + return 0; + } + + var start = 1; // Index zero was already checked for above. + var end = data.length - 1; + + // Binary search for the start of the viewport. + while (end >= start) { + int searchIndex = ((end - start) / 2).floor() + start; + int prevIndex = searchIndex - 1; + + var comparisonValue = domainAxis.compareDomainValueToViewport( + domainFn(data[searchIndex], searchIndex)); + var prevComparisonValue = domainAxis + .compareDomainValueToViewport(domainFn(data[prevIndex], prevIndex)); + + // Found start? + if (prevComparisonValue == -1 && comparisonValue == 0) { + return searchIndex; + } + + // Straddling viewport? + // Return previous index as the nearest start of the viewport. + if (comparisonValue == 1 && prevComparisonValue == -1) { + return (searchIndex - 1); + } + + // Before start? Update startIndex + if (comparisonValue == -1) { + start = searchIndex + 1; + } else { + // Middle or after viewport? Update endIndex + end = searchIndex - 1; + } + } + + // Binary search would reach this point for the edge cases where the domain + // specified is prior or after the domain viewport. + // If domain is prior to the domain viewport, return the first index as the + // nearest viewport start. + // If domain is after the domain viewport, return the last index as the + // nearest viewport start. + var lastComparison = domainAxis.compareDomainValueToViewport( + domainFn(data[data.length - 1], data.length - 1)); + return lastComparison == -1 ? (data.length - 1) : 0; + } + + @visibleForTesting + int findNearestViewportEnd( + Axis domainAxis, AccessorFn domainFn, List data) { + var start = 1; + var end = data.length - 1; + + // Quick optimization for full viewport (likely). + if (domainAxis.compareDomainValueToViewport(domainFn(data[end], end)) == + 0) { + return end; + } + end = end - 1; // Last index was already checked for above. + + // Binary search for the start of the viewport. + while (end >= start) { + int searchIndex = ((end - start) / 2).floor() + start; + int prevIndex = searchIndex - 1; + + int comparisonValue = domainAxis.compareDomainValueToViewport( + domainFn(data[searchIndex], searchIndex)); + int prevComparisonValue = domainAxis + .compareDomainValueToViewport(domainFn(data[prevIndex], prevIndex)); + + // Found end? + if (prevComparisonValue == 0 && comparisonValue == 1) { + return prevIndex; + } + + // Straddling viewport? + // Return the current index as the start of the viewport. + if (comparisonValue == 1 && prevComparisonValue == -1) { + return searchIndex; + } + + // After end? Update endIndex + if (comparisonValue == 1) { + end = searchIndex - 1; + } else { + // Middle or before viewport? Update startIndex + start = searchIndex + 1; + } + } + + // Binary search would reach this point for the edge cases where the domain + // specified is prior or after the domain viewport. + // If domain is prior to the domain viewport, return the first index as the + // nearest viewport end. + // If domain is after the domain viewport, return the last index as the + // nearest viewport end. + var lastComparison = domainAxis.compareDomainValueToViewport( + domainFn(data[data.length - 1], data.length - 1)); + return lastComparison == -1 ? (data.length - 1) : 0; + } +} diff --git a/charts_common/lib/src/chart/common/base_chart.dart b/charts_common/lib/src/chart/common/base_chart.dart new file mode 100644 index 000000000..78286dd83 --- /dev/null +++ b/charts_common/lib/src/chart/common/base_chart.dart @@ -0,0 +1,531 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle, Point; + +import 'package:meta/meta.dart' show protected; + +import 'behavior/chart_behavior.dart' show ChartBehavior; +import 'chart_canvas.dart' show ChartCanvas; +import 'chart_context.dart' show ChartContext; +import 'datum_details.dart' show DatumDetails; +import 'series_renderer.dart' show SeriesRenderer, rendererIdKey, rendererKey; +import 'processed_series.dart' show MutableSeries; +import '../layout/layout_view.dart' show LayoutView; +import '../layout/layout_config.dart' show LayoutConfig; +import '../layout/layout_manager.dart' show LayoutManager; +import '../layout/layout_manager_impl.dart' show LayoutManagerImpl; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../../data/series.dart' show Series; +import '../../common/gesture_listener.dart' show GestureListener; +import '../../common/proxy_gesture_listener.dart' show ProxyGestureListener; +import 'selection_model/selection_model.dart' + show SelectionModel, SelectionModelType; + +abstract class BaseChart { + ChartContext context; + + @protected + GraphicsFactory graphicsFactory; + + LayoutManager _layoutManager; + + int _chartWidth; + int _chartHeight; + + Duration transition = const Duration(milliseconds: 750); + double animationPercent; + + bool _animationsTemporarilyDisabled = false; + + List> _currentSeriesList; + Set _usingRenderers = new Set(); + Map>> _rendererToSeriesList; + + var _seriesRenderers = >{}; + + /// Map of named chart behaviors attached to this chart. + final _behaviorRoleMap = >{}; + final _behaviorStack = >[]; + + final _gestureProxy = new ProxyGestureListener(); + + final _selectionModels = >{}; + + final _lifecycleListeners = >[]; + + BaseChart({LayoutConfig layoutConfig}) { + _layoutManager = new LayoutManagerImpl(config: layoutConfig); + } + + void init(ChartContext context, GraphicsFactory graphicsFactory) { + this.context = context; + + // When graphics factory is updated, update all the views. + if (this.graphicsFactory != graphicsFactory) { + this.graphicsFactory = graphicsFactory; + + _layoutManager.applyToViews( + (LayoutView view) => view.graphicsFactory = graphicsFactory); + } + } + + int get chartWidth => _chartWidth; + + int get chartHeight => _chartHeight; + + // + // Gesture proxy methods + // + ProxyGestureListener get gestureProxy => _gestureProxy; + + /// Add a [GestureListener] to this chart. + GestureListener addGestureListener(GestureListener listener) { + _gestureProxy.listeners.add(listener); + return listener; + } + + /// Remove a [GestureListener] from this chart. + void removeGestureListener(GestureListener listener) { + _gestureProxy.listeners.remove(listener); + } + + LifecycleListener addLifecycleListener(LifecycleListener listener) { + _lifecycleListeners.add(listener); + return listener; + } + + bool removeLifecycleListener(LifecycleListener listener) => + _lifecycleListeners.remove(listener); + + /// Returns a SelectionModel for the given type. Lazy creates one upon first + /// request. + SelectionModel getSelectionModel(SelectionModelType type) { + return _selectionModels.putIfAbsent(type, () => new SelectionModel()); + } + + // + // Renderer methods + // + + set defaultRenderer(SeriesRenderer renderer) { + renderer.rendererId = SeriesRenderer.defaultRendererId; + addSeriesRenderer(renderer); + } + + SeriesRenderer get defaultRenderer => + getSeriesRenderer(SeriesRenderer.defaultRendererId); + + void addSeriesRenderer(SeriesRenderer renderer) { + String rendererId = renderer.rendererId; + + SeriesRenderer previousRenderer = _seriesRenderers[rendererId]; + if (previousRenderer != null) { + removeView(previousRenderer); + previousRenderer.onDetach(this); + } + + addView(renderer); + renderer.onAttach(this); + _seriesRenderers[rendererId] = renderer; + } + + SeriesRenderer getSeriesRenderer(String rendererId) { + SeriesRenderer renderer = _seriesRenderers[rendererId]; + + // Special case, if we are asking for the default and we haven't made it + // yet, then make it now. + if (renderer == null) { + if (rendererId == SeriesRenderer.defaultRendererId) { + renderer = makeDefaultRenderer(); + defaultRenderer = renderer; + } + } + // TODO: throw error if couldn't find renderer by id? + + return renderer; + } + + SeriesRenderer makeDefaultRenderer(); + + bool pointWithinRenderer(Point chartPosition) { + return _usingRenderers.any((String rendererId) => + getSeriesRenderer(rendererId) + .componentBounds + .containsPoint(chartPosition)); + } + + List> getNearestDatumDetailPerSeries( + Point drawAreaPoint) { + final details = >[]; + _usingRenderers.forEach((String rendererId) { + details.addAll(getSeriesRenderer(rendererId) + .getNearestDatumDetailPerSeries(drawAreaPoint)); + }); + + // Sort so that the nearest one is first. + // Special sort, sort by domain distance first, then by measure distance. + details.sort((DatumDetails a, DatumDetails b) { + int domainDiff = a.domainDistance.compareTo(b.domainDistance); + if (domainDiff == 0) { + return a.measureDistance.compareTo(b.measureDistance); + } + return domainDiff; + }); + + return details; + } + + // + // Behavior methods + // + + /// Attaches a behavior to the chart. + /// + /// Setting a new behavior with the same role as a behavior already attached + /// to the chart will replace the old behavior. The old behavior's removeFrom + /// method will be called before we attach the new behavior. + void addBehavior(ChartBehavior behavior) { + final role = behavior.role; + + if (role != null && _behaviorRoleMap[role] != behavior) { + // Remove any old behavior with the same role. + removeBehavior(_behaviorRoleMap[role]); + // Add the new behavior. + _behaviorRoleMap[role] = behavior; + } + + // Add the behavior if it wasn't already added. + if (!_behaviorStack.contains(behavior)) { + _behaviorStack.add(behavior); + behavior.attachTo(this); + } + } + + /// Removes a behavior from the chart. + /// + /// Returns true if a behavior was removed, otherwise returns false. + bool removeBehavior(ChartBehavior behavior) { + if (behavior == null) { + return false; + } + + final role = behavior?.role; + if (role != null && _behaviorRoleMap[role] == behavior) { + _behaviorRoleMap.remove(role); + } + + final wasAttached = _behaviorStack.remove(behavior); + behavior.removeFrom(this); + + return wasAttached; + } + + // + // Layout methods + // + void measure(int width, int height) { + if (_rendererToSeriesList != null) { + _layoutManager.measure(width, height); + } + } + + void layout(int width, int height) { + if (_rendererToSeriesList != null) { + layoutInternal(width, height); + + onPostLayout(_rendererToSeriesList); + } + } + + void layoutInternal(int width, int height) { + _chartWidth = width; + _chartHeight = height; + _layoutManager.layout(width, height); + } + + void addView(LayoutView view) { + if (_layoutManager.isAttached(view) == false) { + view.graphicsFactory = graphicsFactory; + _layoutManager.addView(view); + } + } + + void removeView(LayoutView view) { + _layoutManager.removeView(view); + } + + /// Returns whether or not [point] is within the draw area bounds. + bool withinDrawArea(Point point) { + return _layoutManager.withinDrawArea(point); + } + + /// Returns the bounds of the chart draw area. + Rectangle get drawAreaBounds => _layoutManager.drawAreaBounds; + + // + // Draw methods + // + void draw(List> seriesList) { + var processedSeriesList = new List>.from( + seriesList.map((Series series) => makeSeries(series))); + + // Allow listeners to manipulate the seriesList. + fireOnDraw(processedSeriesList); + + _currentSeriesList = processedSeriesList; + + drawInternal(processedSeriesList, skipAnimation: false, skipLayout: false); + } + + /// Redraws and re-lays-out the chart using the previously rendered layout + /// dimensions. + void redraw({bool skipAnimation = false, bool skipLayout = false}) { + drawInternal(_currentSeriesList, + skipAnimation: skipAnimation, skipLayout: skipLayout); + + // Trigger layout and actually redraw the chart. + if (!skipLayout) { + measure(_chartWidth, _chartHeight); + layout(_chartWidth, _chartHeight); + } else { + onSkipLayout(); + } + } + + void drawInternal(List> seriesList, + {bool skipAnimation, bool skipLayout}) { + seriesList = seriesList + .map((MutableSeries series) => + new MutableSeries.clone(series)) + .toList(); + + // TODO: Handle exiting renderers. + _animationsTemporarilyDisabled = skipAnimation; + + // Allow listeners to manipulate the processed seriesList. + fireOnPreprocess(seriesList); + + _rendererToSeriesList = preprocessSeries(seriesList); + + // Allow listeners to manipulate the processed seriesList. + fireOnPostprocess(seriesList); + } + + MutableSeries makeSeries(Series series) { + final s = new MutableSeries(series); + + // Setup the Renderer + final rendererId = + series.getAttribute(rendererIdKey) ?? SeriesRenderer.defaultRendererId; + s.setAttr(rendererIdKey, rendererId); + s.setAttr(rendererKey, getSeriesRenderer(rendererId)); + + return s; + } + + /// Preprocess series to allow stacking and other mutations. + /// Build a map rendererId to series. + Map>> preprocessSeries( + List> seriesList) { + Map>> rendererToSeriesList = {}; + + var unusedRenderers = _usingRenderers; + _usingRenderers = new Set(); + + // Build map of rendererIds to SeriesLists. + seriesList.forEach((MutableSeries series) { + String rendererId = series.getAttr(rendererIdKey); + rendererToSeriesList.putIfAbsent(rendererId, () => []).add(series); + + _usingRenderers.add(rendererId); + unusedRenderers.remove(rendererId); + }); + + // Allow unused renderers to render out content. + unusedRenderers + .forEach((String rendererId) => rendererToSeriesList[rendererId] = []); + + // Have each renderer preprocess their seriesLists. + rendererToSeriesList + .forEach((String rendererId, List> seriesList) { + getSeriesRenderer(rendererId).preprocessSeries(seriesList); + }); + + return rendererToSeriesList; + } + + void onSkipLayout() { + onPostLayout(_rendererToSeriesList); + } + + void onPostLayout( + Map>> rendererToSeriesList) { + // Update each renderer with + rendererToSeriesList + .forEach((String rendererId, List> seriesList) { + getSeriesRenderer(rendererId).update(seriesList, animatingThisDraw); + }); + + // Request animation + if (animatingThisDraw) { + animationPercent = 0.0; + context.requestAnimation(this.transition); + } else { + animationPercent = 1.0; + context.requestPaint(); + } + + _animationsTemporarilyDisabled = false; + } + + void paint(ChartCanvas canvas) { + canvas.drawingView = 'BaseView'; + _layoutManager.paintOrderedViews.forEach((LayoutView view) { + canvas.drawingView = view.runtimeType.toString(); + view.paint(canvas, animatingThisDraw ? animationPercent : 1.0); + }); + + canvas.drawingView = 'PostRender'; + fireOnPostrender(canvas); + canvas.drawingView = null; + + if (animationPercent == 1.0) { + fireOnAnimationComplete(); + } + } + + bool get animatingThisDraw => (transition != null && + transition.inMilliseconds > 0 && + !_animationsTemporarilyDisabled); + + @protected + fireOnDraw(List> seriesList) { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onData != null) { + listener.onData(seriesList); + } + }); + } + + @protected + fireOnPreprocess(List> seriesList) { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onPreprocess != null) { + listener.onPreprocess(seriesList); + } + }); + } + + @protected + fireOnPostprocess(List> seriesList) { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onPostprocess != null) { + listener.onPostprocess(seriesList); + } + }); + } + + @protected + fireOnAxisConfigured() { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onAxisConfigured != null) { + listener.onAxisConfigured(); + } + }); + } + + @protected + fireOnPostrender(ChartCanvas canvas) { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onPostrender != null) { + listener.onPostrender(canvas); + } + }); + } + + @protected + fireOnAnimationComplete() { + _lifecycleListeners.forEach((LifecycleListener listener) { + if (listener.onAnimationComplete != null) { + listener.onAnimationComplete(); + } + }); + } + + /// Called to free up any resources due to chart going away. + destroy() { + // Walk them in add order to support behaviors that remove other behaviors. + for (var i = 0; i < _behaviorStack.length; i++) { + _behaviorStack[i].removeFrom(this); + } + _behaviorStack.clear(); + _behaviorRoleMap.clear(); + _selectionModels.values.forEach( + (SelectionModel selectionModel) => selectionModel.clearListeners()); + } +} + +class LifecycleListener { + /// Called when new data is drawn to the chart (not a redraw). + /// + /// This step is good for processing the data (running averages, percentage of + /// first, etc). It can also be used to add Series of data (trend line) or + /// remove a line as mentioned above, removing Series. + final LifecycleSeriesListCallback onData; + + /// Called for every redraw given the original SeriesList resulting from the + /// previous onData. + /// + /// This step is good for injecting default attributes on the Series before + /// the renderers process the data (ex: before stacking measures). + final LifecycleSeriesListCallback onPreprocess; + + /// Called after the chart and renderers get a chance to process the data but + /// before the axes process them. + /// + /// This step is good if you need to alter the Series measure values after the + /// renderers have processed them (ex: after stacking measures). + final LifecycleSeriesListCallback onPostprocess; + + /// Called after the Axes have been configured. + /// This step is good if you need to use the axes to get any cartesian + /// location information. At this point Axes should be immutable and stable. + final LifecycleEmptyCallback onAxisConfigured; + + /// Called after the chart is done rendering passing along the canvas allowing + /// a behavior or other listener to render on top of the chart. + /// + /// This is a convenience callback, however if there is any significant canvas + /// interaction or stacking needs, it is preferred that a AplosView/ChartView + /// is added to the chart instead to fully participate in the view stacking. + final LifecycleCanvasCallback onPostrender; + + /// Called after animation hits 100%. This allows a behavior or other listener + /// to chain animations to create a multiple step animation transition. + final LifecycleEmptyCallback onAnimationComplete; + + LifecycleListener( + {this.onData, + this.onPreprocess, + this.onPostprocess, + this.onAxisConfigured, + this.onPostrender, + this.onAnimationComplete}); +} + +typedef LifecycleSeriesListCallback(List> seriesList); +typedef LifecycleCanvasCallback(ChartCanvas canvas); +typedef LifecycleEmptyCallback(); diff --git a/charts_common/lib/src/chart/common/behavior/a11y/a11y_explore_behavior.dart b/charts_common/lib/src/chart/common/behavior/a11y/a11y_explore_behavior.dart new file mode 100644 index 000000000..18604287b --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/a11y/a11y_explore_behavior.dart @@ -0,0 +1,97 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/gesture_listener.dart' show GestureListener; +import '../../base_chart.dart' show BaseChart; +import '../chart_behavior.dart' show ChartBehavior; +import 'a11y_node.dart' show A11yNode; + +/// The gesture to use for triggering explore mode. +enum ExploreModeTrigger { + pressHold, + tap, +} + +/// Chart behavior for adding A11y information. +abstract class A11yExploreBehavior implements ChartBehavior { + /// The gesture that activates explore mode. Defaults to long press. + /// + /// Turning on explore mode asks this [A11yExploreBehavior] to generate nodes within + /// this chart. + final ExploreModeTrigger exploreModeTrigger; + + /// Minimum width of the bounding box for the a11y focus. + /// + /// Must be 1 or higher because invisible semantic nodes should not be added. + final double minimumWidth; + + /// Optionally notify the OS when explore mode is enabled. + final String exploreModeEnabledAnnouncement; + + /// Optionally notify the OS when explore mode is disabled. + final String exploreModeDisabledAnnouncement; + + BaseChart _chart; + GestureListener _listener; + bool _exploreModeOn = false; + + A11yExploreBehavior({ + this.exploreModeTrigger = ExploreModeTrigger.pressHold, + double minimumWidth, + this.exploreModeEnabledAnnouncement, + this.exploreModeDisabledAnnouncement, + }) : minimumWidth = minimumWidth ?? 1.0 { + assert(this.minimumWidth >= 1.0); + + switch (exploreModeTrigger) { + case ExploreModeTrigger.pressHold: + _listener = new GestureListener(onLongPress: _toggleExploreMode); + break; + case ExploreModeTrigger.tap: + _listener = new GestureListener(onTap: _toggleExploreMode); + break; + } + } + + bool _toggleExploreMode(_) { + if (_exploreModeOn) { + _exploreModeOn = false; + // Ask native platform to turn off explore mode. + _chart.context.disableA11yExploreMode( + announcement: exploreModeDisabledAnnouncement); + } else { + _exploreModeOn = true; + // Ask native platform to turn on explore mode. + _chart.context.enableA11yExploreMode(createA11yNodes(), + announcement: exploreModeEnabledAnnouncement); + } + + return true; + } + + /// Returns a list of A11yNodes for this chart. + List createA11yNodes(); + + @override + void attachTo(BaseChart chart) { + _chart = chart; + chart.addGestureListener(_listener); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeGestureListener(_listener); + } +} diff --git a/charts_common/lib/src/chart/common/behavior/a11y/a11y_node.dart b/charts_common/lib/src/chart/common/behavior/a11y/a11y_node.dart new file mode 100644 index 000000000..b79ebba80 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/a11y/a11y_node.dart @@ -0,0 +1,32 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; + +typedef void OnFocus(); + +/// Container for accessibility data. +class A11yNode { + /// The bounding box for this node. + final Rectangle boundingBox; + + /// The textual description of this node. + final String label; + + /// Callback when the A11yNode is focused by the native platform + OnFocus onFocus; + + A11yNode(this.label, this.boundingBox, {this.onFocus}); +} diff --git a/charts_common/lib/src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart b/charts_common/lib/src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart new file mode 100644 index 000000000..2d0b50c84 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart @@ -0,0 +1,192 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:meta/meta.dart' show required; +import '../../../cartesian/axis/axis.dart' show ImmutableAxis, domainAxisKey; +import '../../../cartesian/cartesian_chart.dart' show CartesianChart; +import '../../base_chart.dart' show BaseChart, LifecycleListener; +import '../../processed_series.dart' show MutableSeries, SeriesDatum; +import '../../selection_model/selection_model.dart' show SelectionModelType; +import 'a11y_explore_behavior.dart' + show A11yExploreBehavior, ExploreModeTrigger; +import 'a11y_node.dart' show A11yNode, OnFocus; + +/// Returns a string for a11y vocalization from a list of series datum. +typedef String VocalizationCallback(List> seriesDatums); + +/// A simple vocalization that returns the domain value to string. +String domainVocalization(List> seriesDatums) { + final T datum = seriesDatums.first.datum; + final domainFn = seriesDatums.first.series.domainFn; + final domain = domainFn(datum, null); + + return domain.toString(); +} + +/// Behavior that generates semantic nodes for each domain. +class DomainA11yExploreBehavior extends A11yExploreBehavior { + final VocalizationCallback _vocalizationCallback; + LifecycleListener _lifecycleListener; + CartesianChart _chart; + List> _seriesList; + + DomainA11yExploreBehavior( + {VocalizationCallback vocalizationCallback, + ExploreModeTrigger exploreModeTrigger, + double minimumWidth, + String exploreModeEnabledAnnouncement, + String exploreModeDisabledAnnouncement}) + : _vocalizationCallback = vocalizationCallback ?? domainVocalization, + super( + exploreModeTrigger: exploreModeTrigger, + minimumWidth: minimumWidth, + exploreModeEnabledAnnouncement: exploreModeEnabledAnnouncement, + exploreModeDisabledAnnouncement: exploreModeDisabledAnnouncement) { + _lifecycleListener = + new LifecycleListener(onPostprocess: _updateSeriesList); + } + + @override + List createA11yNodes() { + final nodes = <_DomainA11yNode>[]; + + // Update the selection model when the a11y node has focus. + final selectionModel = _chart.getSelectionModel(SelectionModelType.info); + + final domainSeriesDatum = >>{}; + + for (MutableSeries series in _seriesList) { + for (var index = 0; index < series.data.length; index++) { + T datum = series.data[index]; + D domain = series.domainFn(datum, index); + + domainSeriesDatum[domain] ??= new List>(); + domainSeriesDatum[domain].add(new SeriesDatum(series, datum)); + } + } + + domainSeriesDatum.forEach((D domain, List> seriesDatums) { + final a11yDescription = _vocalizationCallback(seriesDatums); + + final firstSeries = seriesDatums.first.series; + final domainAxis = firstSeries.getAttr(domainAxisKey) as ImmutableAxis; + final location = domainAxis.getLocation(domain); + + /// If the step size is smaller than the minimum width, use minimum. + final stepSize = (domainAxis.stepSize > minimumWidth) + ? domainAxis.stepSize + : minimumWidth; + + nodes.add(new _DomainA11yNode(a11yDescription, + location: location, + stepSize: stepSize, + chartDrawBounds: _chart.drawAreaBounds, + isRTL: _chart.context.rtl, + renderVertically: _chart.vertical, + onFocus: () => selectionModel.updateSelection(seriesDatums, []))); + }); + + // The screen reader navigates the nodes based on the order it is returned. + // So if the chart is RTL, then the nodes should be ordered with the right + // most domain first. + // + // If the chart has multiple series and one series is missing the domain + // and it was added later, we still want the domains to be in order. + nodes.sort(); + + return nodes; + } + + void _updateSeriesList(List> seriesList) { + _seriesList = seriesList; + } + + @override + void attachTo(BaseChart chart) { + // Domain selection behavior only works for cartesian charts. + assert(chart is CartesianChart); + _chart = chart as CartesianChart; + + chart.addLifecycleListener(_lifecycleListener); + + super.attachTo(chart); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeLifecycleListener(_lifecycleListener); + } + + @override + String get role => 'DomainA11yExplore-${exploreModeTrigger}'; +} + +/// A11yNode with domain specific information. +class _DomainA11yNode extends A11yNode implements Comparable<_DomainA11yNode> { + // Save location, RTL, and is render vertically for sorting + final double location; + final bool isRTL; + final bool renderVertically; + + factory _DomainA11yNode(String label, + {@required double location, + @required double stepSize, + @required Rectangle chartDrawBounds, + @required bool isRTL, + @required bool renderVertically, + OnFocus onFocus}) { + Rectangle boundingBox; + if (renderVertically) { + var left = (location - stepSize / 2).round(); + var top = chartDrawBounds.top; + var width = stepSize.round(); + var height = chartDrawBounds.height; + boundingBox = new Rectangle(left, top, width, height); + } else { + var left = chartDrawBounds.left; + var top = (location - stepSize / 2).round(); + var width = chartDrawBounds.width; + var height = stepSize.round(); + boundingBox = new Rectangle(left, top, width, height); + } + + return new _DomainA11yNode._internal(label, boundingBox, + location: location, + isRTL: isRTL, + renderVertically: renderVertically, + onFocus: onFocus); + } + + _DomainA11yNode._internal(String label, Rectangle boundingBox, + {@required this.location, + @required this.isRTL, + @required this.renderVertically, + OnFocus onFocus}) + : super(label, boundingBox, onFocus: onFocus); + + @override + int compareTo(_DomainA11yNode other) { + // Ordered by smaller location first, unless rendering vertically and RTL, + // then flip to sort by larger location first. + int result = location.compareTo(other.location); + + if (renderVertically && isRTL && result != 0) { + result = -result; + } + + return result; + } +} diff --git a/charts_common/lib/src/chart/common/behavior/chart_behavior.dart b/charts_common/lib/src/chart/common/behavior/chart_behavior.dart new file mode 100644 index 000000000..0606bb6f8 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/chart_behavior.dart @@ -0,0 +1,29 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../base_chart.dart'; + +/// Interface for adding behavior to a chart. +/// +/// For example pan and zoom are implemented via behavior strategies. +abstract class ChartBehavior { + String get role; + + /// Injects the behavior into a chart. + void attachTo(BaseChart chart); + + /// Removes the behavior from a chart. + void removeFrom(BaseChart chart); +} diff --git a/charts_common/lib/src/chart/common/behavior/domain_highlighter.dart b/charts_common/lib/src/chart/common/behavior/domain_highlighter.dart new file mode 100644 index 000000000..30868457d --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/domain_highlighter.dart @@ -0,0 +1,83 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../base_chart.dart' show BaseChart, LifecycleListener; +import '../processed_series.dart' show MutableSeries; +import '../selection_model/selection_model.dart' + show SelectionModel, SelectionModelType; +import 'chart_behavior.dart' show ChartBehavior; + +/// Chart behavior that monitors the specified [SelectionModel] and darkens the +/// color for selected data. +/// +/// This is typically used for bars and pies to highlight segments. +/// +/// It is used in combination with SelectNearest to update the selection model +/// and expand selection out to the domain value. +class DomainHighlighter implements ChartBehavior { + final SelectionModelType selectionModelType; + + BaseChart _chart; + + LifecycleListener _lifecycleListener; + + DomainHighlighter([this.selectionModelType = SelectionModelType.info]) { + _lifecycleListener = + new LifecycleListener(onPostprocess: _updateColorFunctions); + } + + void _selectionChanged(SelectionModel selectionModel) { + _chart.redraw(skipLayout: true, skipAnimation: true); + } + + void _updateColorFunctions(List> seriesList) { + SelectionModel selectionModel = + _chart.getSelectionModel(selectionModelType); + seriesList.forEach((MutableSeries series) { + final origColorFn = series.colorFn; + + if (origColorFn != null) { + series.colorFn = (T datum, int index) { + final origColor = origColorFn(datum, index); + if (selectionModel.isDatumSelected(series, datum)) { + return origColor.darker; + } else { + return origColor; + } + }; + } + }); + } + + @override + void attachTo(BaseChart chart) { + _chart = chart; + chart.addLifecycleListener(_lifecycleListener); + chart + .getSelectionModel(selectionModelType) + .addSelectionListener(_selectionChanged); + } + + @override + void removeFrom(BaseChart chart) { + chart + .getSelectionModel(selectionModelType) + .removeSelectionListener(_selectionChanged); + chart.removeLifecycleListener(_lifecycleListener); + } + + @override + String get role => 'domainHighlight-${selectionModelType.toString()}'; +} diff --git a/charts_common/lib/src/chart/common/behavior/legend/legend.dart b/charts_common/lib/src/chart/common/behavior/legend/legend.dart new file mode 100644 index 000000000..aad0c82c2 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/legend/legend.dart @@ -0,0 +1,114 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import '../../base_chart.dart' show BaseChart, LifecycleListener; +import '../../chart_context.dart' show ChartContext; +import '../../processed_series.dart' show MutableSeries; +import '../../selection_model/selection_model.dart' + show SelectionModel, SelectionModelType; +import '../chart_behavior.dart' show ChartBehavior; +import 'legend_entry.dart'; +import 'legend_entry_generator.dart'; +import 'per_series_legend_entry_generator.dart'; + +// TODO: Allow tapping on a series to remove series from draw area. +// TODO: Allows for hovering over a series in legend to highlight +// corresponding series in draw area. + +/// Series legend behavior for charts. +/// +/// By default this behavior creates a legend entry per series. +class SeriesLegend extends Legend { + SeriesLegend( + {SelectionModelType selectionModelType, + LegendEntryGenerator legendEntryGenerator}) + : super( + selectionModelType: selectionModelType ?? SelectionModelType.info, + legendEntryGenerator: + legendEntryGenerator ?? const PerSeriesLegendEntryGenerator()); +} + +/// Legend behavior for charts. +/// +/// Since legends are desired to be customizable, building and displaying the +/// visual content of legends is done on the native platforms. This allows users +/// to specify customized content for legends using the native platform (ex. for +/// Flutter, using widgets). +abstract class Legend implements ChartBehavior { + final SelectionModelType selectionModelType; + final legendState = new LegendState(); + final LegendEntryGenerator legendEntryGenerator; + + BaseChart _chart; + LifecycleListener _lifecycleListener; + + Legend({this.selectionModelType, this.legendEntryGenerator}) { + _lifecycleListener = new LifecycleListener(onPostprocess: _postProcess); + } + + /// Build LegendEntries from list of series. + void _postProcess(List> seriesList) { + legendState._legendEntries = + legendEntryGenerator.getLegendEntries(seriesList); + updateLegend(); + } + + /// Update the legend state with [selectionModel] and request legend update. + void _selectionChanged(SelectionModel selectionModel) { + legendState._selectionModel = selectionModel; + legendEntryGenerator.updateLegendEntries( + legendState.legendEntries, legendState.selectionModel); + updateLegend(); + } + + ChartContext get chartContext => _chart.context; + + // Gets the draw area bounds for native legend content to position itself + // accordingly. + Rectangle get drawAreaBounds => _chart.drawAreaBounds; + + /// Requires override to show in native platform + void updateLegend() {} + + @override + void attachTo(BaseChart chart) { + _chart = chart; + chart.addLifecycleListener(_lifecycleListener); + chart + .getSelectionModel(selectionModelType) + .addSelectionListener(_selectionChanged); + } + + @override + void removeFrom(BaseChart chart) { + chart + .getSelectionModel(selectionModelType) + .removeSelectionListener(_selectionChanged); + chart.removeLifecycleListener(_lifecycleListener); + } + + @override + String get role => 'legend-${selectionModelType.toString()}'; +} + +/// Stores legend data used by native legend content builder. +class LegendState { + List> _legendEntries; + SelectionModel _selectionModel; + + List> get legendEntries => _legendEntries; + SelectionModel get selectionModel => _selectionModel; +} diff --git a/charts_common/lib/src/chart/common/behavior/legend/legend_entry.dart b/charts_common/lib/src/chart/common/behavior/legend/legend_entry.dart new file mode 100644 index 000000000..f419e89d3 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/legend/legend_entry.dart @@ -0,0 +1,49 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../../../common/color.dart'; +import '../../../../common/symbol_renderer.dart'; +import '../../series_renderer.dart' show rendererKey; +import '../../processed_series.dart' show ImmutableSeries; + +/// Holder for the information used for a legend row. +/// +/// [T] the datum class type for the series passed in. +/// [D] the domain class type for the datum. +class LegendEntry { + final String label; + final ImmutableSeries series; + final T datum; + final int datumIndex; + final D domain; + final double value; + final Color color; + bool isSelected; + + // TODO: Forward the default formatters from series and allow for + // native legends to provide separate formatters. + + LegendEntry(this.series, this.label, + {this.datum, + this.datumIndex, + this.domain, + this.value, + this.color, + this.isSelected: false}); + + /// Get the native symbol renderer stored in the series. + SymbolRenderer get symbolRenderer => + series.getAttr(rendererKey).symbolRenderer; +} diff --git a/charts_common/lib/src/chart/common/behavior/legend/legend_entry_generator.dart b/charts_common/lib/src/chart/common/behavior/legend/legend_entry_generator.dart new file mode 100644 index 000000000..e498a179a --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/legend/legend_entry_generator.dart @@ -0,0 +1,37 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'legend_entry.dart'; +import '../../selection_model/selection_model.dart'; +import '../../processed_series.dart' show MutableSeries; + +/// A strategy for generating a list of [LegendEntry] based on the series drawn. +/// +/// [T] the datum class type for chart. +/// [D] the domain class type for the datum. +abstract class LegendEntryGenerator { + /// Generates a list of legend entries based on the series drawn on the chart. + /// + /// [seriesList] Processed series list. + List> getLegendEntries( + List> seriesList); + + /// Update the list of legend entries based on the selection model. + /// + /// [seriesList] Processed series list. + /// [selectionModel] Selection model to query selected state. + void updateLegendEntries(List> legendEntries, + SelectionModel selectionModel); +} diff --git a/charts_common/lib/src/chart/common/behavior/legend/per_series_legend_entry_generator.dart b/charts_common/lib/src/chart/common/behavior/legend/per_series_legend_entry_generator.dart new file mode 100644 index 000000000..956f3ed0d --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/legend/per_series_legend_entry_generator.dart @@ -0,0 +1,46 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'legend_entry.dart'; +import '../../selection_model/selection_model.dart'; +import '../../processed_series.dart' show MutableSeries; +import 'legend_entry_generator.dart'; + +/// A strategy for generating a list of [LegendEntry] per series drawn. +/// +/// [T] the datum class type for chart. +/// [D] the domain class type for the datum. +class PerSeriesLegendEntryGenerator + implements LegendEntryGenerator { + const PerSeriesLegendEntryGenerator(); + + @override + List> getLegendEntries( + List> seriesList) { + return seriesList.map((series) { + final color = series.colorFn(null, null); + return new LegendEntry(series, series.displayName, color: color); + }).toList(); + } + + @override + void updateLegendEntries(List> legendEntries, + SelectionModel selectionModel) { + for (var entry in legendEntries) { + entry.isSelected = selectionModel.selectedSeries + .any((selectedSeries) => entry.series.id == selectedSeries.id); + } + } +} diff --git a/charts_common/lib/src/chart/common/behavior/line_point_highlighter.dart b/charts_common/lib/src/chart/common/behavior/line_point_highlighter.dart new file mode 100644 index 000000000..8b030da6b --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/line_point_highlighter.dart @@ -0,0 +1,487 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show LinkedHashMap; +import 'dart:math' show Point, Rectangle; +import 'package:meta/meta.dart'; + +import '../../cartesian/axis/axis.dart' + show ImmutableAxis, domainAxisKey, measureAxisKey; +import '../base_chart.dart' show BaseChart, LifecycleListener; +import '../chart_canvas.dart' show ChartCanvas, getAnimatedColor; +import '../processed_series.dart' show ImmutableSeries, MutableSeries; +import '../selection_model/selection_model.dart' + show SelectionModel, SelectionModelType; +import 'chart_behavior.dart' show ChartBehavior; +import '../../cartesian/cartesian_chart.dart' show CartesianChart; +import '../../layout/layout_view.dart' + show LayoutPosition, LayoutView, LayoutViewConfig, ViewMeasuredSizes; +import '../../../common/color.dart' show Color; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../common/style/style_factory.dart' show StyleFactory; + +/// Chart behavior that monitors the specified [SelectionModel] and renders a +/// dot for selected data. +/// +/// This is typically used for line charts to highlight segments. +/// +/// It is used in combination with SelectNearest to update the selection model +/// and expand selection out to the domain value. +class LinePointHighlighter implements ChartBehavior { + final SelectionModelType selectionModelType; + + /// Default radius of the dots if the series has no radius mapping function. + /// + /// When no radius mapping function is provided, this value will be used as + /// is. [radiusPaddingPx] will not be added to [defaultRadiusPx]. + final double defaultRadiusPx; + + /// Additional radius value added to the radius of the selected data. + /// + /// This value is only used when the series has a radius mapping function + /// defined. + final double radiusPaddingPx; + + final bool showHorizontalFollowLine; + + final bool showVerticalFollowLine; + + BaseChart _chart; + + _LinePointLayoutView _view; + + LifecycleListener _lifecycleListener; + + List> _seriesList; + + /// Store a map of data drawn on the chart, mapped by series name. + /// + /// [LinkedHashMap] is used to render the series on the canvas in the same + /// order as the data was given to the chart. + final _seriesPointMap = new LinkedHashMap>(); + + // Store a list of points that exist in the series data. + // + // This list will be used to remove any [_AnimatedPoint] that were rendered in + // previous draw cycles, but no longer have a corresponding datum in the new + // data. + final _currentKeys = []; + + LinePointHighlighter( + {this.selectionModelType = SelectionModelType.info, + this.defaultRadiusPx = 4.0, + this.radiusPaddingPx = 0.5, + this.showHorizontalFollowLine = false, + this.showVerticalFollowLine = true}) { + _lifecycleListener = new LifecycleListener( + onPostprocess: _updateSeriesList, onAxisConfigured: _updateViewData); + } + + @override + void attachTo(BaseChart chart) { + _chart = chart; + + _view = new _LinePointLayoutView( + layoutPositionOrder: 20, + showHorizontalFollowLine: showHorizontalFollowLine, + showVerticalFollowLine: showVerticalFollowLine); + + if (chart is CartesianChart) { + // Only vertical rendering is supported by this behavior. + assert((chart as CartesianChart).vertical); + } + + chart.addView(_view); + + chart.addLifecycleListener(_lifecycleListener); + chart + .getSelectionModel(selectionModelType) + .addSelectionListener(_selectionChanged); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeView(_view); + chart + .getSelectionModel(selectionModelType) + .removeSelectionListener(_selectionChanged); + chart.removeLifecycleListener(_lifecycleListener); + } + + void _selectionChanged(SelectionModel selectionModel) { + _chart.redraw(skipLayout: true, skipAnimation: true); + } + + void _updateSeriesList(List> seriesList) { + _seriesList = seriesList; + } + + void _updateViewData() { + _currentKeys.clear(); + + SelectionModel selectionModel = + _chart.getSelectionModel(selectionModelType); + + _seriesList?.forEach((MutableSeries series) { + var domainAxis = series.getAttr(domainAxisKey) as ImmutableAxis; + var domainFn = series.domainFn; + var measureAxis = series.getAttr(measureAxisKey) as ImmutableAxis; + var measureFn = series.measureFn; + var measureOffsetFn = series.measureOffsetFn; + var colorFn = series.colorFn; + var lineKey = series.id; + var radiusPxFn = series.radiusPxFn; + + for (var index = 0; index < series.data.length; index++) { + T datum = series.data[index]; + + if (selectionModel.isDatumSelected(series, datum)) { + final domainValue = domainFn(datum, index); + + Color color = colorFn(datum, index); + + double radiusPx; + if (radiusPxFn != null) { + radiusPx = radiusPxFn(datum, index).toDouble() + radiusPaddingPx; + } else { + radiusPx = defaultRadiusPx; + } + + var pointKey = '${lineKey}::${domainValue}'; + + // If we already have a AnimatingPoint for that index, use it. + _AnimatedPoint animatingPoint; + if (_seriesPointMap.containsKey(pointKey)) { + animatingPoint = _seriesPointMap[pointKey]; + } else { + // Create a new line and have it animate in from axis. + var point = _getPoint( + datum, domainValue, series, domainAxis, 0.0, 0.0, measureAxis); + + animatingPoint = new _AnimatedPoint( + key: pointKey, overlaySeries: series.overlaySeries) + ..setNewTarget(new _PointRendererElement() + ..color = color + ..point = point + ..radiusPx = radiusPx + ..measureAxisPosition = measureAxis.getLocation(0.0)); + + _seriesPointMap[pointKey] = animatingPoint; + } + + // Create a new line using the final point locations. + var point = _getPoint( + datum, + domainValue, + series, + domainAxis, + measureFn(datum, index), + measureOffsetFn(datum, index), + measureAxis); + + // Update the set of lines that still exist in the series data. + _currentKeys.add(pointKey); + + // Get the point element we are going to setup. + final pointElement = new _PointRendererElement() + ..point = point + ..color = color + ..radiusPx = radiusPx + ..measureAxisPosition = measureAxis.getLocation(0.0); + + animatingPoint.setNewTarget(pointElement); + } + } + }); + + // Animate out points that don't exist anymore. + _seriesPointMap.forEach((String key, _AnimatedPoint point) { + if (_currentKeys.contains(point.key) != true) { + point.animateOut(); + } + }); + + _view.seriesPointMap = _seriesPointMap; + } + + _DatumPoint _getPoint( + T datum, + D domainValue, + ImmutableSeries series, + ImmutableAxis domainAxis, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis) { + final domainPosition = domainAxis.getLocation(domainValue); + + final measurePosition = + measureAxis.getLocation(measureValue + measureOffsetValue); + + return new _DatumPoint( + datum: datum, + domain: domainValue, + series: series, + x: domainPosition, + y: measurePosition); + } + + @override + String get role => 'LinePointHighlighter-${selectionModelType.toString()}'; +} + +class _LinePointLayoutView extends LayoutView { + final LayoutViewConfig layoutConfig; + + final bool showHorizontalFollowLine; + + final bool showVerticalFollowLine; + + Rectangle _drawAreaBounds; + Rectangle get drawBounds => _drawAreaBounds; + + GraphicsFactory _graphicsFactory; + + /// Store a map of series drawn on the chart, mapped by series name. + /// + /// [LinkedHashMap] is used to render the series on the canvas in the same + /// order as the data was given to the chart. + LinkedHashMap> _seriesPointMap; + + _LinePointLayoutView({ + @required int layoutPositionOrder, + @required this.showHorizontalFollowLine, + @required this.showVerticalFollowLine, + }) : this.layoutConfig = new LayoutViewConfig( + position: LayoutPosition.DrawArea, + positionOrder: layoutPositionOrder); + + set seriesPointMap(LinkedHashMap> value) { + _seriesPointMap = value; + } + + @override + GraphicsFactory get graphicsFactory => _graphicsFactory; + + @override + set graphicsFactory(GraphicsFactory value) { + _graphicsFactory = value; + } + + @override + ViewMeasuredSizes measure(int maxWidth, int maxHeight) { + return null; + } + + @override + void layout(Rectangle componentBounds, Rectangle drawAreaBounds) { + this._drawAreaBounds = drawAreaBounds; + } + + @override + void paint(ChartCanvas canvas, double animationPercent) { + if (_seriesPointMap == null) { + return; + } + + // Clean up the lines that no longer exist. + if (animationPercent == 1.0) { + final keysToRemove = []; + + _seriesPointMap.forEach((String key, _AnimatedPoint point) { + if (point.animatingOut) { + keysToRemove.add(key); + } + }); + + keysToRemove.forEach((String key) => _seriesPointMap.remove(key)); + } + + _seriesPointMap.forEach((String key, _AnimatedPoint point) { + final pointElement = point.getCurrentPoint(animationPercent); + + // Draw the horizontal follow line. + if (showHorizontalFollowLine) { + canvas.drawLine( + points: [ + new Point(_drawAreaBounds.left, pointElement.point.y), + new Point(_drawAreaBounds.left + _drawAreaBounds.width, + pointElement.point.y), + ], + stroke: StyleFactory.style.linePointHighlighterColor, + strokeWidthPx: 1.0, + dashPattern: [1, 3]); + } + + // Draw the vertical follow line. + if (showVerticalFollowLine) { + canvas.drawLine( + points: [ + new Point(pointElement.point.x, _drawAreaBounds.top), + new Point(pointElement.point.x, + _drawAreaBounds.top + _drawAreaBounds.height), + ], + stroke: StyleFactory.style.linePointHighlighterColor, + strokeWidthPx: 1.0, + dashPattern: [1, 3]); + } + + // Draw the highlight dot. + canvas.drawPoint( + point: pointElement.point, + fill: pointElement.color, + radius: pointElement.radiusPx); + }); + } + + @override + Rectangle get componentBounds => this._drawAreaBounds; +} + +class _DatumPoint extends Point { + final T datum; + final D domain; + final ImmutableSeries series; + + _DatumPoint({this.datum, this.domain, this.series, double x, double y}) + : super(x, y); + + factory _DatumPoint.from(_DatumPoint other, [double x, double y]) { + return new _DatumPoint( + datum: other.datum, + domain: other.domain, + series: other.series, + x: x ?? other.x, + y: y ?? other.y); + } +} + +class _PointRendererElement { + _DatumPoint point; + Color color; + double radiusPx; + double measureAxisPosition; + + _PointRendererElement clone() { + return new _PointRendererElement() + ..point = this.point + ..color = this.color + ..measureAxisPosition = this.measureAxisPosition + ..radiusPx = this.radiusPx; + } + + void updateAnimationPercent(_PointRendererElement previous, + _PointRendererElement target, double animationPercent) { + final targetPoint = target.point; + final previousPoint = previous.point; + + final x = ((targetPoint.x - previousPoint.x) * animationPercent) + + previousPoint.x; + + final y = ((targetPoint.y - previousPoint.y) * animationPercent) + + previousPoint.y; + + point = new _DatumPoint.from(targetPoint, x, y); + + color = getAnimatedColor(previous.color, target.color, animationPercent); + + radiusPx = (((target.radiusPx - previous.radiusPx) * animationPercent) + + previous.radiusPx); + } +} + +class _AnimatedPoint { + final String key; + final bool overlaySeries; + + _PointRendererElement _previousPoint; + _PointRendererElement _targetPoint; + _PointRendererElement _currentPoint; + + // Flag indicating whether this point is being animated out of the chart. + bool animatingOut = false; + + _AnimatedPoint({@required this.key, @required this.overlaySeries}); + + /// Animates a point that was removed from the series out of the view. + /// + /// This should be called in place of "setNewTarget" for points that represent + /// data that has been removed from the series. + /// + /// Animates the height of the point down to the measure axis position + /// (position of 0). + void animateOut() { + final newTarget = _currentPoint.clone(); + + // Set the target measure value to the axis position for all points. + final targetPoint = newTarget.point; + + final newPoint = new _DatumPoint.from(targetPoint, targetPoint.x, + newTarget.measureAxisPosition.roundToDouble()); + + newTarget.point = newPoint; + + // Animate the radius to 0 so that we don't get a lingering point after + // animation is done. + newTarget.radiusPx = 0.0; + + setNewTarget(newTarget); + animatingOut = true; + } + + void setNewTarget(_PointRendererElement newTarget) { + animatingOut = false; + _currentPoint ??= newTarget.clone(); + _previousPoint = _currentPoint; + _targetPoint = newTarget; + } + + _PointRendererElement getCurrentPoint(double animationPercent) { + if (animationPercent == 1.0 || _previousPoint == null) { + _currentPoint = _targetPoint; + _previousPoint = _targetPoint; + return _currentPoint; + } + + _currentPoint.updateAnimationPercent( + _previousPoint, _targetPoint, animationPercent); + + return _currentPoint; + } +} + +/// Helper class that exposes fewer private internal properties for unit tests. +@visibleForTesting +class LinePointHighlighterTester { + final LinePointHighlighter behavior; + + LinePointHighlighterTester(this.behavior); + + int getSelectionLength() { + return behavior._seriesPointMap.length; + } + + bool isDatumSelected(D datum) { + var contains = false; + + behavior._seriesPointMap.forEach((String key, _AnimatedPoint point) { + if (point._currentPoint.point.datum == datum) { + contains = true; + return; + } + }); + + return contains; + } +} diff --git a/charts_common/lib/src/chart/common/behavior/range_annotation.dart b/charts_common/lib/src/chart/common/behavior/range_annotation.dart new file mode 100644 index 000000000..32583b549 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/range_annotation.dart @@ -0,0 +1,446 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show LinkedHashMap; +import 'dart:math' show Point, Rectangle; +import 'package:meta/meta.dart'; + +import '../../cartesian/axis/axis.dart' show ImmutableAxis; +import '../base_chart.dart' show BaseChart, LifecycleListener; +import '../chart_canvas.dart' show ChartCanvas, getAnimatedColor; +import '../processed_series.dart' show MutableSeries; +import 'chart_behavior.dart' show ChartBehavior; +import '../../cartesian/cartesian_chart.dart' show CartesianChart; +import '../../layout/layout_view.dart' + show LayoutPosition, LayoutView, LayoutViewConfig, ViewMeasuredSizes; +import '../../../common/color.dart' show Color; +import '../../../common/graphics_factory.dart' show GraphicsFactory; +import '../../../common/style/style_factory.dart' show StyleFactory; + +/// Chart behavior that annotates domain ranges with a solid fill color. +/// +/// The annotations will be drawn underneath series data and chart axes. +/// +/// This is typically used for line charts to call out sections of the data +/// range. +/// +/// TODO: Support labels. +class RangeAnnotation implements ChartBehavior { + /// List of annotations to render on the chart. + final List annotations; + + /// Default color for annotations. + final Color defaultColor; + + /// Whether or not the range of the axis should be extended to include the + /// annotation start and end values. + final bool extendAxis; + + CartesianChart _chart; + + _RangeAnnotationLayoutView _view; + + LifecycleListener _lifecycleListener; + + /// Store a map of data drawn on the chart, mapped by series name. + /// + /// [LinkedHashMap] is used to render the series on the canvas in the same + /// order as the data was given to the chart. + final _annotationMap = new LinkedHashMap>(); + + // Store a list of annotations that exist in the current annotation list. + // + // This list will be used to remove any [_AnimatedAnnotation] that were + // rendered in previous draw cycles, but no longer have a corresponding datum + // in the new data. + final _currentKeys = []; + + RangeAnnotation(this.annotations, + {Color defaultColor, this.extendAxis = true}) + : defaultColor = StyleFactory.style.rangeAnnotationColor { + _lifecycleListener = new LifecycleListener( + onPostprocess: _updateAxisRange, onAxisConfigured: _updateViewData); + } + + @override + void attachTo(BaseChart chart) { + if (!(chart is CartesianChart)) { + throw new ArgumentError( + 'RangeAnnotation can only be attached to a CartesianChart'); + } + + _chart = chart; + + _view = new _RangeAnnotationLayoutView( + layoutPositionOrder: -1, defaultColor: defaultColor); + + chart.addView(_view); + + chart.addLifecycleListener(_lifecycleListener); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeView(_view); + chart.removeLifecycleListener(_lifecycleListener); + } + + void _updateAxisRange(List> seriesList) { + // Extend the axis range if enabled. + if (extendAxis) { + final domainAxis = _chart.domainAxis; + + annotations.forEach((RangeAnnotationSegment annotation) { + var axis; + + switch (annotation.axisType) { + case RangeAnnotationAxisType.domain: + axis = domainAxis; + break; + + case RangeAnnotationAxisType.measure: + // We expect an empty axisId to get us the primary measure axis. + axis = _chart.getMeasureAxis(annotation.axisId); + break; + } + + axis.addDomainValue(annotation.startValue); + axis.addDomainValue(annotation.endValue); + }); + } + } + + void _updateViewData() { + _currentKeys.clear(); + + annotations.forEach((RangeAnnotationSegment annotation) { + var axis; + + switch (annotation.axisType) { + case RangeAnnotationAxisType.domain: + axis = _chart.domainAxis; + break; + + case RangeAnnotationAxisType.measure: + // We expect an empty axisId to get us the primary measure axis. + axis = _chart.getMeasureAxis(annotation.axisId); + break; + } + + final key = '${annotation.axisType}::${annotation.axisId}::' + + '${annotation.startValue}::${annotation.endValue}'; + + final color = annotation.color ?? defaultColor; + + final annotationDatum = _getAnnotationDatum(annotation.startValue, + annotation.endValue, axis, annotation.axisType); + + // If we already have a animatingAnnotation for that index, use it. + _AnimatedAnnotation animatingAnnotation; + if (_annotationMap.containsKey(key)) { + animatingAnnotation = _annotationMap[key]; + } else { + // Create a new annotation, positioned at the start and end values. + animatingAnnotation = new _AnimatedAnnotation(key: key) + ..setNewTarget(new _AnnotationElement() + ..annotation = annotationDatum + ..color = color); + + _annotationMap[key] = animatingAnnotation; + } + + // Update the set of annotations that still exist in the series data. + _currentKeys.add(key); + + // Get the annotation element we are going to setup. + final annotationElement = new _AnnotationElement() + ..annotation = annotationDatum + ..color = color; + + animatingAnnotation.setNewTarget(annotationElement); + }); + + // Animate out annotations that don't exist anymore. + _annotationMap.forEach((String key, _AnimatedAnnotation annotation) { + if (_currentKeys.contains(annotation.key) != true) { + annotation.animateOut(); + } + }); + + _view.annotationMap = _annotationMap; + } + + /// Generates a datum that describes an annotation. + _DatumAnnotation _getAnnotationDatum(D startValue, D endValue, + ImmutableAxis axis, RangeAnnotationAxisType axisType) { + final startPosition = axis.getLocation(startValue); + final endPosition = axis.getLocation(endValue); + + return new _DatumAnnotation( + startPosition: startPosition, + endPosition: endPosition, + axisType: axisType); + } + + @override + String get role => 'RangeAnnotation'; +} + +class _RangeAnnotationLayoutView extends LayoutView { + final LayoutViewConfig layoutConfig; + + final Color defaultColor; + + Rectangle _drawAreaBounds; + Rectangle get drawBounds => _drawAreaBounds; + + GraphicsFactory _graphicsFactory; + + /// Store a map of series drawn on the chart, mapped by series name. + /// + /// [LinkedHashMap] is used to render the series on the canvas in the same + /// order as the data was given to the chart. + LinkedHashMap> _annotationMap; + + _RangeAnnotationLayoutView({ + @required int layoutPositionOrder, + @required this.defaultColor, + }) : this.layoutConfig = new LayoutViewConfig( + position: LayoutPosition.DrawArea, + positionOrder: layoutPositionOrder); + + set annotationMap(LinkedHashMap> value) { + _annotationMap = value; + } + + @override + GraphicsFactory get graphicsFactory => _graphicsFactory; + + @override + set graphicsFactory(GraphicsFactory value) { + _graphicsFactory = value; + } + + @override + ViewMeasuredSizes measure(int maxWidth, int maxHeight) { + return null; + } + + @override + void layout(Rectangle componentBounds, Rectangle drawAreaBounds) { + this._drawAreaBounds = drawAreaBounds; + } + + @override + void paint(ChartCanvas canvas, double animationPercent) { + if (_annotationMap == null) { + return; + } + + // Clean up the annotations that no longer exist. + if (animationPercent == 1.0) { + final keysToRemove = []; + + _annotationMap + .forEach((String key, _AnimatedAnnotation annotation) { + if (annotation.animatingOut) { + keysToRemove.add(key); + } + }); + + keysToRemove.forEach((String key) => _annotationMap.remove(key)); + } + + _annotationMap.forEach((String key, _AnimatedAnnotation annotation) { + final annotationElement = + annotation.getCurrentAnnotation(animationPercent); + + switch (annotationElement.annotation.axisType) { + case RangeAnnotationAxisType.domain: + canvas.drawRect( + new Rectangle( + annotationElement.annotation.startPosition, + _drawAreaBounds.top, + annotationElement.annotation.endPosition - + annotationElement.annotation.startPosition, + _drawAreaBounds.height), + fill: annotationElement.color); + break; + + case RangeAnnotationAxisType.measure: + canvas.drawRect( + new Rectangle( + _drawAreaBounds.left, + annotationElement.annotation.endPosition, + _drawAreaBounds.left + _drawAreaBounds.width, + annotationElement.annotation.startPosition - + annotationElement.annotation.endPosition), + fill: annotationElement.color); + break; + } + }); + } + + @override + Rectangle get componentBounds => this._drawAreaBounds; +} + +class _DatumAnnotation { + final double startPosition; + final double endPosition; + final RangeAnnotationAxisType axisType; + + _DatumAnnotation({this.startPosition, this.endPosition, this.axisType}); + + factory _DatumAnnotation.from(_DatumAnnotation other, + [double startPosition, double endPosition]) { + return new _DatumAnnotation( + startPosition: startPosition ?? other.startPosition, + endPosition: endPosition ?? other.endPosition, + axisType: other.axisType); + } +} + +class _AnnotationElement { + _DatumAnnotation annotation; + Color color; + String label; + Point labelPosition; + + _AnnotationElement clone() { + return new _AnnotationElement() + ..annotation = this.annotation + ..color = this.color + ..label = this.label + ..labelPosition = labelPosition; + } + + void updateAnimationPercent(_AnnotationElement previous, + _AnnotationElement target, double animationPercent) { + final targetAnnotation = target.annotation; + final previousAnnotation = previous.annotation; + + final startPosition = + ((targetAnnotation.startPosition - previousAnnotation.startPosition) * + animationPercent) + + previousAnnotation.startPosition; + + final endPosition = + ((targetAnnotation.endPosition - previousAnnotation.endPosition) * + animationPercent) + + previousAnnotation.endPosition; + + annotation = new _DatumAnnotation.from( + targetAnnotation, startPosition, endPosition); + + color = getAnimatedColor(previous.color, target.color, animationPercent); + } +} + +class _AnimatedAnnotation { + final String key; + + _AnnotationElement _previousAnnotation; + _AnnotationElement _targetAnnotation; + _AnnotationElement _currentAnnotation; + + // Flag indicating whether this annotation is being animated out of the chart. + bool animatingOut = false; + + _AnimatedAnnotation({@required this.key}); + + /// Animates an annotation that was removed from the list out of the view. + /// + /// This should be called in place of "setNewTarget" for annotations have been + /// removed from the list. + /// TODO: Needed? + void animateOut() { + final newTarget = _currentAnnotation.clone(); + + setNewTarget(newTarget); + animatingOut = true; + } + + void setNewTarget(_AnnotationElement newTarget) { + animatingOut = false; + _currentAnnotation ??= newTarget.clone(); + _previousAnnotation = _currentAnnotation; + _targetAnnotation = newTarget; + } + + _AnnotationElement getCurrentAnnotation(double animationPercent) { + if (animationPercent == 1.0 || _previousAnnotation == null) { + _currentAnnotation = _targetAnnotation; + _previousAnnotation = _targetAnnotation; + return _currentAnnotation; + } + + _currentAnnotation.updateAnimationPercent( + _previousAnnotation, _targetAnnotation, animationPercent); + + return _currentAnnotation; + } +} + +/// Helper class that exposes fewer private internal properties for unit tests. +@visibleForTesting +class RangeAnnotationTester { + final RangeAnnotation behavior; + + RangeAnnotationTester(this.behavior); + + /// Checks if an annotation exists with the given position and color. + bool doesAnnotationExist(num startPosition, num endPosition, Color color) { + var exists = false; + + behavior._annotationMap.forEach((String key, _AnimatedAnnotation a) { + final currentAnnotation = a._currentAnnotation; + final annotation = currentAnnotation.annotation; + + if (annotation.startPosition == startPosition && + annotation.endPosition == endPosition && + currentAnnotation.color == color) { + exists = true; + return; + } + }); + + return exists; + } +} + +/// Data for a chart annotation. +class RangeAnnotationSegment { + final D startValue; + final D endValue; + final RangeAnnotationAxisType axisType; + final String axisId; + final Color color; + final String label; + final AnnotationLabelDirection labelDirection; + + RangeAnnotationSegment(this.startValue, this.endValue, this.axisType, + {this.axisId, this.color, this.label, this.labelDirection}); +} + +enum RangeAnnotationAxisType { + domain, + measure, +} + +enum AnnotationLabelDirection { + horizontal, + vertical, +} diff --git a/charts_common/lib/src/chart/common/behavior/select_nearest.dart b/charts_common/lib/src/chart/common/behavior/select_nearest.dart new file mode 100644 index 000000000..514cf187f --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/select_nearest.dart @@ -0,0 +1,181 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import '../base_chart.dart' show BaseChart; +import '../datum_details.dart' show DatumDetails; +import '../behavior/chart_behavior.dart' show ChartBehavior; +import '../processed_series.dart' show ImmutableSeries, SeriesDatum; +import '../selection_model/selection_model.dart' show SelectionModelType; +import '../../../common/gesture_listener.dart' show GestureListener; + +enum SelectNearestTrigger { + hover, + tap, + tapAndDrag, + pressHold, + longPressHold, +} + +/// Chart behavior that listens to the given eventTrigger and updates the +/// specified [SelectionModel]. This is used to pair input events to behaviors +/// that listen to selection changes. +/// +/// Input event types: +/// hover (default) - Mouse over/near data. +/// tap - Mouse/Touch on/near data. +/// pressHold - Mouse/Touch and drag across the data instead of panning. +/// longPressHold - Mouse/Touch for a while in one place then drag across the +/// data. +/// +/// SelectionModels that can be updated: +/// info - To view the details of the selected items (ie: hover for web). +/// action - To select an item as an input, drill, or other selection. +/// +/// Other options available +/// expandToDomain - all data points that match the domain value of the +/// closest data point will be included in the selection. (Default: true) +/// selectClosestSeries - mark the series for the closest data point as +/// selected. (Default: true) +/// +/// You can add one SelectNearest for each model type that you are updating. +/// Any previous SelectNearest behavior for that selection model will be +/// removed. +class SelectNearest implements ChartBehavior { + GestureListener _listener; + + final SelectionModelType selectionModelType; + final SelectNearestTrigger eventTrigger; + final bool expandToDomain; + final bool selectClosestSeries; + BaseChart _chart; + + bool delaySelect = false; + + SelectNearest( + {this.selectionModelType = SelectionModelType.info, + this.expandToDomain = true, + this.selectClosestSeries = true, + this.eventTrigger = SelectNearestTrigger.hover}) { + // Setup the appropriate gesture listening. + switch (this.eventTrigger) { + case SelectNearestTrigger.tap: + _listener = + new GestureListener(onTapTest: _onTapTest, onTap: _onSelect); + break; + case SelectNearestTrigger.tapAndDrag: + _listener = new GestureListener( + onTapTest: _onTapTest, + onTap: _onSelect, + onDragStart: _onSelect, + onDragUpdate: _onSelect, + ); + break; + case SelectNearestTrigger.pressHold: + _listener = new GestureListener( + onTapTest: _onTapTest, + onLongPress: _onSelect, + onDragStart: _onSelect, + onDragUpdate: _onSelect, + onDragEnd: _onDeselectAll); + break; + case SelectNearestTrigger.longPressHold: + _listener = new GestureListener( + onTapTest: _onTapTest, + onLongPress: _onLongPressSelect, + onDragStart: _onSelect, + onDragUpdate: _onSelect, + onDragEnd: _onDeselectAll); + break; + case SelectNearestTrigger.hover: + default: + _listener = new GestureListener(onHover: _onSelect); + break; + } + } + + bool _onTapTest(Point chartPoint) { + // If the tap is within the drawArea, then claim the event from others. + delaySelect = eventTrigger == SelectNearestTrigger.longPressHold; + return _chart.pointWithinRenderer(chartPoint); + } + + bool _onLongPressSelect(Point chartPoint) { + delaySelect = false; + return _onSelect(chartPoint); + } + + bool _onSelect(Point chartPoint, [double ignored]) { + // If the selection is delayed (waiting for long press), then quit early. + if (delaySelect) { + return false; + } + + var details = _chart.getNearestDatumDetailPerSeries(chartPoint); + + final seriesList = >[]; + final seriesDatumList = >[]; + + if (details.isNotEmpty) { + details = expandToDomain ? _expandToDomain(details) : [details.first]; + } + + details.forEach((DatumDetails details) { + seriesDatumList.add(new SeriesDatum(details.series, details.datum)); + + if (selectClosestSeries && seriesList.isEmpty) { + seriesList.add(details.series); + } + }); + + return _chart + .getSelectionModel(selectionModelType) + .updateSelection(seriesDatumList, seriesList); + } + + bool _onDeselectAll(_, __, ___) { + // If the selection is delayed (waiting for long press), then quit early. + if (delaySelect) { + return false; + } + + _chart + .getSelectionModel(selectionModelType) + .updateSelection(>[], >[]); + return false; + } + + List> _expandToDomain(List> details) => + details + .where((DatumDetails detail) => + detail.domain == details.first.domain) + .toList(); + + @override + void attachTo(BaseChart chart) { + _chart = chart; + chart.addGestureListener(_listener); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeGestureListener(_listener); + _chart = null; + } + + @override + String get role => 'SelectNearest-${selectionModelType.toString()}}'; +} diff --git a/charts_common/lib/src/chart/common/behavior/zoom/pan_and_zoom_behavior.dart b/charts_common/lib/src/chart/common/behavior/zoom/pan_and_zoom_behavior.dart new file mode 100644 index 000000000..a367b8506 --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/zoom/pan_and_zoom_behavior.dart @@ -0,0 +1,112 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show min, max, Point; + +import 'package:meta/meta.dart' show protected; + +import '../../../cartesian/cartesian_chart.dart'; +import 'pan_behavior.dart'; + +/// Adds domain axis panning and zooming support to the chart. +/// +/// Zooming is supported for the web by mouse wheel events. Scrolling up zooms +/// the chart in, and scrolling down zooms the chart out. The chart can never be +/// zoomed out past the domain axis range. +/// +/// Zooming is supported by pinch gestures for mobile devices. +/// +/// Panning is supported by clicking and dragging the mouse for web, or tapping +/// and dragging on the chart for mobile devices. +class PanAndZoomBehavior extends PanBehavior { + @override + String get role => 'PanAndZoom'; + + /// Flag which is enabled to indicate that the user is "zooming" the chart. + bool _isZooming = false; + + @protected + bool get isZooming => _isZooming; + + /// Current zoom scaling factor for the behavior. + double _scalingFactor = 1.0; + + /// Minimum scalingFactor to prevent zooming out beyond the data range. + final _minScalingFactor = 1.0; + + /// Maximum scalingFactor to prevent zooming in so far that no data is + /// visible. + /// + /// TODO: Dynamic max based on data range? + final _maxScalingFactor = 5.0; + + @override + bool onDragStart(Point localPosition) { + if (chart == null) { + return false; + } + + super.onDragStart(localPosition); + + // Save the current scaling factor to make zoom events relative. + _scalingFactor = + (chart as CartesianChart)?.domainAxis?.viewportScalingFactor; + _isZooming = true; + return true; + } + + @override + bool onDragUpdate(Point localPosition, double scale) { + // Swipe gestures should be handled by the [PanBehavior]. + if (scale == 1.0) { + _isZooming = false; + return super.onDragUpdate(localPosition, scale); + } + + // No further events in this chain should be handled by [PanBehavior]. + cancelPanning(); + + if (!_isZooming || lastPosition == null || chart == null) { + return false; + } + + // Update the domain axis's viewport scale factor to zoom the chart. + final domainAxis = (chart as CartesianChart).domainAxis; + + if (domainAxis == null) { + return false; + } + + // Clamp the scale to prevent zooming out beyond the range of the data, or + // zooming in so far that we show nothing useful. + final newScalingFactor = + min(max(_scalingFactor * scale, _minScalingFactor), _maxScalingFactor); + + domainAxis.setViewportSettings( + newScalingFactor, domainAxis.viewportTranslatePx, + drawAreaWidth: chart.drawAreaBounds.width); + + chart.redraw(skipAnimation: true, skipLayout: true); + + return true; + } + + @override + bool onDragEnd( + Point localPosition, double scale, double pixelsPerSec) { + _isZooming = false; + return super.onDragEnd(localPosition, scale, pixelsPerSec); + } +} diff --git a/charts_common/lib/src/chart/common/behavior/zoom/pan_behavior.dart b/charts_common/lib/src/chart/common/behavior/zoom/pan_behavior.dart new file mode 100644 index 000000000..cf91b970b --- /dev/null +++ b/charts_common/lib/src/chart/common/behavior/zoom/pan_behavior.dart @@ -0,0 +1,187 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; + +import 'package:meta/meta.dart' show protected; + +import '../../base_chart.dart'; +import '../../../cartesian/cartesian_chart.dart'; +import '../../../cartesian/axis/axis.dart' show Axis; +import '../chart_behavior.dart'; +import '../../../../common/gesture_listener.dart'; + +/// Adds domain axis panning support to a chart. +/// +/// Panning is supported by clicking and dragging the mouse for web, or tapping +/// and dragging on the chart for mobile devices. +class PanBehavior implements ChartBehavior { + @override + String get role => 'Pan'; + + /// Listens for drag gestures. + GestureListener _listener; + + /// The chart to which the behavior is attached. + BaseChart _chart; + + @protected + BaseChart get chart => _chart; + + /// Flag which is enabled to indicate that the user is "panning" the chart. + bool _isPanning = false; + + @protected + bool get isPanning => _isPanning; + + /// Last position of the mouse/tap that was used to adjust the scale translate + /// factor. + Point _lastPosition; + + @protected + Point get lastPosition => _lastPosition; + + PanBehavior() { + _listener = new GestureListener( + onTapTest: onTapTest, + onDragStart: onDragStart, + onDragUpdate: onDragUpdate, + onDragEnd: onDragEnd); + } + + /// Injects the behavior into a chart. + attachTo(BaseChart chart) { + if (!(chart is CartesianChart)) { + throw new ArgumentError( + 'PanBehavior can only be attached to a CartesianChart'); + } + + _chart = chart; + chart.addGestureListener(_listener); + + // Disable the autoViewport feature to enable panning. + (_chart as CartesianChart).domainAxis?.autoViewport = false; + + /// TODO: Lock the measure axis during panning & flinging. + } + + /// Removes the behavior from a chart. + removeFrom(BaseChart chart) { + if (!(chart is CartesianChart)) { + throw new ArgumentError( + 'PanBehavior can only be attached to a CartesianChart'); + } + + chart.removeGestureListener(_listener); + + // Restore the default autoViewport state. + (_chart as CartesianChart).domainAxis?.autoViewport = true; + + _chart = null; + } + + @protected + bool onTapTest(Point localPosition) { + if (_chart == null) { + return false; + } + + return _chart.withinDrawArea(localPosition); + } + + @protected + bool onDragStart(Point localPosition) { + if (_chart == null) { + return false; + } + + onPanStart(); + + _lastPosition = localPosition; + _isPanning = true; + return true; + } + + @protected + bool onDragUpdate(Point localPosition, double scale) { + if (!_isPanning || _lastPosition == null || _chart == null) { + return false; + } + + // Pinch gestures should be handled by the [PanAndZoomBehavior]. + if (scale != 1.0) { + _isPanning = false; + return false; + } + + // Update the domain axis's viewport translate to pan the chart. + final domainAxis = (_chart as CartesianChart).domainAxis; + + if (domainAxis == null) { + return false; + } + + double domainScalingFactor = domainAxis.viewportScalingFactor; + + double domainChange = + domainAxis.viewportTranslatePx + localPosition.x - _lastPosition.x; + + domainAxis.setViewportSettings(domainScalingFactor, domainChange, + drawAreaWidth: chart.drawAreaBounds.width); + + _lastPosition = localPosition; + + _chart.redraw(skipAnimation: true, skipLayout: true); + return true; + } + + @protected + bool onDragEnd( + Point localPosition, double scale, double pixelsPerSec) { + onPanEnd(); + return true; + } + + @protected + void onPanStart() { + final CartesianChart cartesianChart = _chart; + // When panning starts, domain axis should update tick location only. + // TODO: Panning should generate a set of ticks before and after + // current viewport that is used for panning. + cartesianChart.domainAxis.updateTickLocationOnly = true; + // When panning starts, measure axes should not update ticks or viewport. + cartesianChart.getMeasureAxis(null).lockAxis = true; + cartesianChart.getMeasureAxis(Axis.secondaryMeasureAxisId)?.lockAxis = true; + } + + @protected + void onPanEnd() { + cancelPanning(); + + final CartesianChart cartesianChart = _chart; + // When panning stops, allow axes to update ticks, and request redraw. + cartesianChart.domainAxis.updateTickLocationOnly = false; + cartesianChart.getMeasureAxis(null).lockAxis = false; + cartesianChart.getMeasureAxis(Axis.secondaryMeasureAxisId)?.lockAxis = + false; + + _chart.redraw(skipAnimation: true); + } + + /// Cancels the handling of any current panning event. + void cancelPanning() { + _isPanning = false; + } +} diff --git a/charts_common/lib/src/chart/common/canvas_shapes.dart b/charts_common/lib/src/chart/common/canvas_shapes.dart new file mode 100644 index 000000000..60131bc06 --- /dev/null +++ b/charts_common/lib/src/chart/common/canvas_shapes.dart @@ -0,0 +1,91 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle, min, max; +import 'chart_canvas.dart' show FillPatternType; +import '../../common/color.dart' show Color; + +/// A rectangle to be painted by [ChartCanvas]. +class CanvasRect { + final Rectangle bounds; + final Color fill; + final FillPatternType pattern; + final Color stroke; + + CanvasRect(this.bounds, {this.fill, this.pattern, this.stroke}); +} + +/// A stack of [CanvasRect] to be painted by [ChartCanvas]. +class CanvasBarStack { + final List segments; + final int radius; + final int stackedBarPadding; + final bool roundTopLeft; + final bool roundTopRight; + final bool roundBottomLeft; + final bool roundBottomRight; + final Rectangle fullStackRect; + + factory CanvasBarStack(List segments, + {int radius, + int stackedBarPadding, + bool roundTopLeft, + bool roundTopRight, + bool roundBottomLeft, + bool roundBottomRight}) { + final firstBarBounds = segments.first.bounds; + + // Find the rectangle that would represent the full stack of bars. + var left = firstBarBounds.left; + var top = firstBarBounds.top; + var right = firstBarBounds.right; + var bottom = firstBarBounds.bottom; + + for (var barIndex = 1; barIndex < segments.length; barIndex++) { + final bounds = segments[barIndex].bounds; + + left = min(left, bounds.left); + top = min(top, bounds.top); + right = max(right, bounds.right); + bottom = max(bottom, bounds.bottom); + } + + final width = right - left; + final height = bottom - top; + final fullStackRect = new Rectangle(left, top, width, height); + + return new CanvasBarStack._internal( + segments, + radius: radius, + stackedBarPadding: stackedBarPadding, + roundTopLeft: roundTopLeft, + roundTopRight: roundTopRight, + roundBottomLeft: roundBottomLeft, + roundBottomRight: roundBottomRight, + fullStackRect: fullStackRect, + ); + } + + CanvasBarStack._internal( + this.segments, { + this.radius, + this.stackedBarPadding = 1, + this.roundTopLeft = false, + this.roundTopRight = false, + this.roundBottomLeft = false, + this.roundBottomRight = false, + this.fullStackRect, + }); +} diff --git a/charts_common/lib/src/chart/common/chart_canvas.dart b/charts_common/lib/src/chart/common/chart_canvas.dart new file mode 100644 index 000000000..1b17ba0b5 --- /dev/null +++ b/charts_common/lib/src/chart/common/chart_canvas.dart @@ -0,0 +1,81 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point, Rectangle; +import 'canvas_shapes.dart' show CanvasBarStack; +import '../../common/color.dart' show Color; +import '../../common/text_element.dart' show TextElement; + +abstract class ChartCanvas { + /// Set the name of the view doing the rendering for debugging purposes, + /// or null when we believe rendering is complete. + set drawingView(String viewName); + + /// Renders a simple line. + /// + /// [dashPattern] controls the pattern of dashes and gaps in a line. It is a + /// list of lengths of alternating dashes and gaps. The rendering is similar + /// to stroke-dasharray in SVG path elements. An odd number of values in the + /// pattern will be repeated to derive an even number of values. "1,2,3" is + /// equivalent to "1,2,3,1,2,3." + void drawLine( + {List points, + Color fill, + Color stroke, + bool roundEndCaps, + double strokeWidthPx, + List dashPattern}); + + /// Renders a simple point. + void drawPoint({Point point, Color fill, double radius}); + + /// Renders a simple rectangle. + void drawRect(Rectangle bounds, {Color fill, Color stroke}); + + /// Renders a rounded rectangle. + void drawRRect(Rectangle bounds, + {Color fill, + Color stroke, + num radius, + bool roundTopLeft, + bool roundTopRight, + bool roundBottomLeft, + bool roundBottomRight}); + + /// Renders a stack of bars, rounding the last bar in the stack. + /// + /// The first bar of the stack is expected to be the "base" bar. This would + /// be the bottom most bar for a vertically rendered bar. + void drawBarStack(CanvasBarStack canvasBarStack); + + void drawText(TextElement textElement, int offsetX, int offsetY); +} + +Color getAnimatedColor(Color previous, Color target, double animationPercent) { + var r = (((target.r - previous.r) * animationPercent) + previous.r).round(); + var g = (((target.g - previous.g) * animationPercent) + previous.g).round(); + var b = (((target.b - previous.b) * animationPercent) + previous.b).round(); + var a = (((target.a - previous.a) * animationPercent) + previous.a).round(); + + return new Color(a: a, r: r, g: g, b: b); +} + +/// Defines the pattern for a color fill. +/// +/// * [forwardHatch] defines a pattern of white lines angled up and to the right +/// on top of a bar filled with the fill color. +/// * [solid] defines a simple bar filled with the fill color. This is the +/// default pattern for bars. +enum FillPatternType { forwardHatch, solid } diff --git a/charts_common/lib/src/chart/common/chart_context.dart b/charts_common/lib/src/chart/common/chart_context.dart new file mode 100644 index 000000000..ba56869ff --- /dev/null +++ b/charts_common/lib/src/chart/common/chart_context.dart @@ -0,0 +1,38 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../common/date_time_factory.dart'; +import '../../common/rtl_spec.dart' show RTLSpec; +import '../common/behavior/a11y/a11y_node.dart' show A11yNode; + +abstract class ChartContext { + bool get rtl; + + RTLSpec get rtlSpec; + + double get pixelsPerDp; + + DateTimeFactory get dateTimeFactory; + + void requestRedraw(); + + void requestAnimation(Duration transition); + + void requestPaint(); + + void enableA11yExploreMode(List nodes, {String announcement}); + + void disableA11yExploreMode({String announcement}); +} diff --git a/charts_common/lib/src/chart/common/datum_details.dart b/charts_common/lib/src/chart/common/datum_details.dart new file mode 100644 index 000000000..8419bcb29 --- /dev/null +++ b/charts_common/lib/src/chart/common/datum_details.dart @@ -0,0 +1,31 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'processed_series.dart' show ImmutableSeries; + +class DatumDetails { + final T datum; + final D domain; + final ImmutableSeries series; + final double domainDistance; + final double measureDistance; + + DatumDetails( + {this.datum, + this.domain, + this.series, + this.domainDistance, + this.measureDistance}); +} diff --git a/charts_common/lib/src/chart/common/processed_series.dart b/charts_common/lib/src/chart/common/processed_series.dart new file mode 100644 index 000000000..c8d4a878b --- /dev/null +++ b/charts_common/lib/src/chart/common/processed_series.dart @@ -0,0 +1,151 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../cartesian/axis/axis.dart' show Axis; +import '../common/chart_canvas.dart' show FillPatternType; +import '../../data/series.dart' + show AccessorFn, Series, SeriesAttributes, AttributeKey; +import '../../common/color.dart' show Color; + +class MutableSeries extends ImmutableSeries { + final String id; + String displayName; + String seriesCategory; + bool overlaySeries; + + List data; + + AccessorFn domainFn; + AccessorFn measureFn; + AccessorFn measureUpperBoundFn; + AccessorFn measureLowerBoundFn; + AccessorFn measureOffsetFn; + AccessorFn colorFn; + AccessorFn fillPatternFn; + AccessorFn labelAccessorFn; + AccessorFn radiusPxFn; + AccessorFn strokeWidthPxFn; + + List dashPattern; + + final _attrs = new SeriesAttributes(); + + Axis measureAxis; + Axis domainAxis; + + MutableSeries(Series series) : this.id = series.id { + displayName = series.displayName ?? series.id; + seriesCategory = series.seriesCategory; + overlaySeries = series.overlaySeries; + + data = series.data; + domainFn = series.domainFn; + + measureFn = series.measureFn; + measureUpperBoundFn = series.measureUpperBoundFn; + measureLowerBoundFn = series.measureLowerBoundFn; + measureOffsetFn = series.measureOffsetFn; + + colorFn = series.colorFn; + fillPatternFn = series.fillPatternFn; + labelAccessorFn = + series.labelAccessorFn ?? (d, i) => domainFn(d, i).toString(); + radiusPxFn = series.radiusPxFn; + strokeWidthPxFn = series.strokeWidthPxFn; + + dashPattern = series.dashPattern; + + _attrs.mergeFrom(series.attributes); + } + + MutableSeries.clone(MutableSeries other) : this.id = other.id { + displayName = other.displayName; + seriesCategory = other.seriesCategory; + overlaySeries = other.overlaySeries; + + data = other.data; + domainFn = other.domainFn; + + measureFn = other.measureFn; + measureUpperBoundFn = other.measureUpperBoundFn; + measureLowerBoundFn = other.measureLowerBoundFn; + measureOffsetFn = other.measureOffsetFn; + + colorFn = other.colorFn; + fillPatternFn = other.fillPatternFn; + labelAccessorFn = other.labelAccessorFn; + radiusPxFn = other.radiusPxFn; + strokeWidthPxFn = other.strokeWidthPxFn; + + dashPattern = other.dashPattern; + + _attrs.mergeFrom(other._attrs); + measureAxis = other.measureAxis; + domainAxis = other.domainAxis; + } + + void setAttr(AttributeKey key, R value) { + this._attrs.setAttr(key, value); + } + + R getAttr(AttributeKey key) { + return this._attrs.getAttr(key); + } + + bool operator ==(Object other) => + other is MutableSeries && data == other.data && id == other.id; + + @override + int get hashCode => data.hashCode * 31 + id.hashCode; +} + +abstract class ImmutableSeries { + String get id; + String get displayName; + String get seriesCategory; + bool get overlaySeries; + + List get data; + + AccessorFn get domainFn; + AccessorFn get measureFn; + AccessorFn get measureUpperBoundFn; + AccessorFn get measureLowerBoundFn; + AccessorFn get measureOffsetFn; + AccessorFn get colorFn; + AccessorFn get fillPatternFn; + AccessorFn get labelAccessorFn; + AccessorFn get radiusPxFn; + AccessorFn get strokeWidthPxFn; + + List get dashPattern; + + void setAttr(AttributeKey key, R value); + R getAttr(AttributeKey key); +} + +class SeriesDatum { + final ImmutableSeries series; + final T datum; + + SeriesDatum(this.series, this.datum); + + @override + bool operator ==(Object other) => + other is SeriesDatum && other.series == series && other.datum == datum; + + @override + int get hashCode => series.hashCode * 31 + datum.hashCode; +} diff --git a/charts_common/lib/src/chart/common/selection_model/selection_model.dart b/charts_common/lib/src/chart/common/selection_model/selection_model.dart new file mode 100644 index 000000000..0f95c8f58 --- /dev/null +++ b/charts_common/lib/src/chart/common/selection_model/selection_model.dart @@ -0,0 +1,112 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:collection/collection.dart' show ListEquality; + +import '../processed_series.dart' show ImmutableSeries, SeriesDatum; + +/// Holds the state of interaction or selection for the chart to coordinate +/// between various event sources and things that wish to act upon the selection +/// state (highlight, drill, etc). +/// +/// There is one instance per interaction type (ex: info, action) with each +/// maintaining their own state. Info is typically used to update a hover/touch +/// card while action is used in case of a secondary selection/action. +/// +/// The series selection state is kept separate from datum selection state to +/// allow more complex highlighting. For example: a Hovercard that shows entries +/// for each datum for a given domain/time, but highlights the closest entry to +/// match up with highlighting/bolding of the line and legend. +class SelectionModel { + final _listeners = >[]; + var _selectedDatum = >[]; + var _selectedSeries = >[]; + + /// When set to true, prevents the model from being updated. + bool locked = false; + + /// Updates the selection state. If mouse driven, [datumSelection] should be + /// ordered by distance from mouse, closest first. + bool updateSelection(List> datumSelection, + List> seriesList) { + if (locked) { + return false; + } + + final origSelectedDatum = _selectedDatum; + final origSelectedSeries = _selectedSeries; + + _selectedDatum = datumSelection; + _selectedSeries = seriesList; + + final changed = + !new ListEquality().equals(origSelectedDatum, _selectedDatum) || + !new ListEquality().equals(origSelectedSeries, _selectedSeries); + if (changed) { + _listeners.forEach((listener) => listener(this)); + } + return changed; + } + + /// Returns true if this [SelectionModel] has a selected datum. + bool get hasDatumSelection => _selectedDatum.isNotEmpty; + + bool isDatumSelected(ImmutableSeries series, T datum) => + _selectedDatum.contains(new SeriesDatum(series, datum)); + + /// Returns the selected [SeriesDatum] for this [SelectionModel]. + /// + /// This is empty by default. + List> get selectedDatum => _selectedDatum; + + /// Returns true if this [SelectionModel] has a selected series. + bool get hasSeriesSelection => _selectedSeries.isNotEmpty; + + /// Returns the selected [ImmutableSeries] for this [SelectionModel]. + /// + /// This is empty by default. + List> get selectedSeries => _selectedSeries; + + /// Add a listener to be notified when this [SelectionModel] changes. + /// + /// Note: the listener will not be triggered if [updateSelection] is called + /// resulting in the same selection state. + addSelectionListener(SelectionModelListener listener) { + _listeners.add(listener); + } + + /// Remove listener from being notified when this [SelectionModel] changes. + removeSelectionListener(SelectionModelListener listener) { + _listeners.remove(listener); + } + + clearListeners() { + _listeners.clear(); + } +} + +/// Callback for SelectionModel. It is triggered when the selection state +/// changes. +typedef SelectionModelListener(SelectionModel model); + +enum SelectionModelType { + /// Typical Hover or Details event for viewing the details of the selected + /// items. + info, + + /// Typical Selection, Drill or Input event likely updating some external + /// content. + action, +} diff --git a/charts_common/lib/src/chart/common/series_renderer.dart b/charts_common/lib/src/chart/common/series_renderer.dart new file mode 100644 index 000000000..79e8a4225 --- /dev/null +++ b/charts_common/lib/src/chart/common/series_renderer.dart @@ -0,0 +1,198 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point, Rectangle, max; +import 'package:meta/meta.dart'; +import 'base_chart.dart' show BaseChart; +import 'chart_canvas.dart' show ChartCanvas; +import 'datum_details.dart' show DatumDetails; +import 'processed_series.dart' show ImmutableSeries, MutableSeries; +import '../layout/layout_view.dart' + show LayoutView, LayoutPosition, LayoutViewConfig, ViewMeasuredSizes; +import '../../common/color.dart' show Color; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../../common/symbol_renderer.dart' show SymbolRenderer; +import '../../common/style/style_factory.dart' show StyleFactory; +import '../../data/series.dart' show AttributeKey; + +const AttributeKey rendererIdKey = + const AttributeKey('SeriesRenderer.rendererId'); + +const AttributeKey rendererKey = + const AttributeKey('SeriesRenderer.renderer'); + +abstract class SeriesRenderer extends LayoutView { + static const defaultRendererId = 'default'; + + SymbolRenderer get symbolRenderer; + + String get rendererId; + set rendererId(String rendererId); + + void onAttach(BaseChart chart); + + void onDetach(BaseChart chart); + + void preprocessSeries(List> seriesList); + + void configureDomainAxes(List> seriesList); + + void configureMeasureAxes(List> seriesList); + + void update(List> seriesList, bool isAnimating); + + void paint(ChartCanvas canvas, double animationPercent); + + List> getNearestDatumDetailPerSeries( + Point chartPoint); +} + +abstract class BaseSeriesRenderer implements SeriesRenderer { + final LayoutViewConfig layoutConfig; + + String rendererId; + + Rectangle _drawAreaBounds; + Rectangle get drawBounds => _drawAreaBounds; + + GraphicsFactory _graphicsFactory; + + BaseSeriesRenderer({ + @required this.rendererId, + @required int layoutPositionOrder, + }) : this.layoutConfig = new LayoutViewConfig( + position: LayoutPosition.DrawArea, + positionOrder: layoutPositionOrder); + + @override + GraphicsFactory get graphicsFactory => _graphicsFactory; + + @override + set graphicsFactory(GraphicsFactory value) { + _graphicsFactory = value; + } + + @override + void onAttach(BaseChart chart) {} + + @override + void onDetach(BaseChart chart) {} + + /// Assigns colors to series that are missing their colorFn. + /// + /// [emptyCategoryUsesSinglePalette] Flag indicating whether having all + /// series with no categories will use the same or separate palettes. + /// Setting it to true uses various Blues for each series. + /// Setting it to false used different palettes (ie: s1 uses Blue500, + /// s2 uses Red500), + @protected + assignMissingColors(Iterable seriesList, + {@required bool emptyCategoryUsesSinglePalette}) { + const defaultCategory = '__default__'; + + // Count up the number of missing series per category, keeping a max across + // categories. + final missingColorCountPerCategory = {}; + int maxMissing = 0; + bool hasSpecifiedCategory = false; + + seriesList.forEach((MutableSeries series) { + if (series.colorFn == null) { + // If there is no category, give it a default category to match logic. + String category = series.seriesCategory; + if (category == null) { + category = defaultCategory; + } else { + hasSpecifiedCategory = true; + } + + // Increment the missing counts for the category. + final missingCnt = (missingColorCountPerCategory[category] ?? 0) + 1; + missingColorCountPerCategory[category] = missingCnt; + maxMissing = max(maxMissing, missingCnt); + } + }); + + if (maxMissing > 0) { + // Special handling of only series with empty categories when we want + // to use different palettes. + if (!emptyCategoryUsesSinglePalette && !hasSpecifiedCategory) { + final palettes = StyleFactory.style.getOrderedPalettes(maxMissing); + int index = 0; + seriesList.forEach((MutableSeries series) { + if (series.colorFn == null) { + final color = palettes[index % palettes.length].shadeDefault; + index++; + series.colorFn = (_, __) => color; + } + }); + return; + } + + // Get a list of palettes to use given the number of categories we've + // seen. One palette per category (but might need to repeat). + final colorPalettes = StyleFactory.style + .getOrderedPalettes(missingColorCountPerCategory.length); + + // Create a map of Color palettes for each category. Each Palette uses + // the max for any category to ensure that the gradients look appropriate. + final colorsByCategory = >{}; + int index = 0; + missingColorCountPerCategory.keys.forEach((String category) { + colorsByCategory[category] = + colorPalettes[index % colorPalettes.length].makeShades(maxMissing); + index++; + + // Reset the count so we can use it to count as we set the colorFn. + missingColorCountPerCategory[category] = 0; + }); + + seriesList.forEach((MutableSeries series) { + if (series.colorFn == null) { + final category = series.seriesCategory ?? defaultCategory; + + // Get the current index into the color list. + final colorIndex = missingColorCountPerCategory[category]; + missingColorCountPerCategory[category] = colorIndex + 1; + + final color = colorsByCategory[category][colorIndex]; + series.colorFn = (_, __) => color; + } + }); + } + } + + @override + ViewMeasuredSizes measure(int maxWidth, int maxHeight) { + return null; + } + + @override + void layout(Rectangle componentBounds, Rectangle drawAreaBounds) { + this._drawAreaBounds = drawAreaBounds; + } + + @override + Rectangle get componentBounds => this._drawAreaBounds; + + @override + void preprocessSeries(List> seriesList) {} + + @override + void configureDomainAxes(List> seriesList) {} + + @override + void configureMeasureAxes(List> seriesList) {} +} diff --git a/charts_common/lib/src/chart/common/series_renderer_config.dart b/charts_common/lib/src/chart/common/series_renderer_config.dart new file mode 100644 index 000000000..817957ac4 --- /dev/null +++ b/charts_common/lib/src/chart/common/series_renderer_config.dart @@ -0,0 +1,40 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../common/symbol_renderer.dart'; +import '../../common/typed_registry.dart'; +import 'series_renderer.dart' show SeriesRenderer; + +/// Interface for series renderer configuration. +abstract class SeriesRendererConfig { + /// Stores typed renderer attributes + /// + /// This is useful for storing attributes that is used on the native platform. + /// Such as the SymbolRenderer that is associated with each renderer but is + /// a native builder since legend is built natively. + RendererAttributes get rendererAttributes; + + String get customRendererId; + + SymbolRenderer get symbolRenderer; + + SeriesRenderer build(); +} + +class RendererAttributeKey extends TypedKey { + const RendererAttributeKey(String uniqueKey) : super(uniqueKey); +} + +class RendererAttributes extends TypedRegistry {} diff --git a/charts_common/lib/src/chart/common/unitconverter/identity_converter.dart b/charts_common/lib/src/chart/common/unitconverter/identity_converter.dart new file mode 100644 index 000000000..599d77237 --- /dev/null +++ b/charts_common/lib/src/chart/common/unitconverter/identity_converter.dart @@ -0,0 +1,27 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'unit_converter.dart' show UnitConverter; + +/// A No op unit converter. +class IdentityConverter implements UnitConverter { + const IdentityConverter(); + + @override + convert(U value) => value; + + @override + invert(U value) => value; +} diff --git a/charts_common/lib/src/chart/common/unitconverter/unit_converter.dart b/charts_common/lib/src/chart/common/unitconverter/unit_converter.dart new file mode 100644 index 000000000..e1317f20f --- /dev/null +++ b/charts_common/lib/src/chart/common/unitconverter/unit_converter.dart @@ -0,0 +1,26 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Converts a num value in the 'from' unit to a num value in the 'to' unit. +/// +/// [F] Type of the value in the 'from' units. +/// [T] Type of the value in 'to' units. +abstract class UnitConverter { + /// Converts 'from' unit value to the 'to' unit value. + T convert(F value); + + /// Converts 'to' unit value back to the 'from' unit value. + F invert(T value); +} diff --git a/charts_common/lib/src/chart/layout/layout_config.dart b/charts_common/lib/src/chart/layout/layout_config.dart new file mode 100644 index 000000000..7c05f73b0 --- /dev/null +++ b/charts_common/lib/src/chart/layout/layout_config.dart @@ -0,0 +1,121 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Collection of configurations that apply to the [LayoutManager]. +class LayoutConfig { + final MarginSpec leftSpec; + final MarginSpec rightSpec; + final MarginSpec topSpec; + final MarginSpec bottomSpec; + + /// Create a new [LayoutConfig] used by [DynamicLayoutManager]. + LayoutConfig({ + MarginSpec leftSpec, + MarginSpec rightSpec, + MarginSpec topSpec, + MarginSpec bottomSpec, + }) : leftSpec = leftSpec ?? MarginSpec.defaultSpec, + rightSpec = rightSpec ?? MarginSpec.defaultSpec, + topSpec = topSpec ?? MarginSpec.defaultSpec, + bottomSpec = bottomSpec ?? MarginSpec.defaultSpec; +} + +/// Specs that applies to one margin. +class MarginSpec { + /// [MarginSpec] that has max of 50 percent. + static const defaultSpec = const MarginSpec._internal(null, null, null, 50); + + final int _minPixel; + final int _maxPixel; + final int _minPercent; + final int _maxPercent; + + const MarginSpec._internal( + int minPixel, int maxPixel, int minPercent, int maxPercent) + : _minPixel = minPixel, + _maxPixel = maxPixel, + _minPercent = minPercent, + _maxPercent = maxPercent; + + /// Create [MarginSpec] that specifies min/max pixels. + /// + /// [minPixel] if set must be greater than or equal to 0 and less than max if + /// it is also set. + /// [maxPixel] if set must be greater than or equal to 0. + factory MarginSpec.fromPixel({int minPixel, int maxPixel}) { + // Require zero or higher settings if set + assert(minPixel == null || minPixel >= 0); + assert(maxPixel == null || maxPixel >= 0); + // Min must be less than or equal to max. + // Can be equal to enforce strict pixel size. + if (minPixel != null && maxPixel != null) { + assert(minPixel <= maxPixel); + } + + return new MarginSpec._internal(minPixel, maxPixel, null, null); + } + + /// Create [MarginSpec] with a fixed pixel size [pixels]. + /// + /// [pixels] if set must be greater than or equal to 0. + factory MarginSpec.fixedPixel(int pixels) { + // Require require or higher setting if set + assert(pixels == null || pixels >= 0); + + return new MarginSpec._internal(pixels, pixels, null, null); + } + + /// Create [MarginSpec] that specifies min/max percentage. + /// + /// [minPercent] if set must be between 0 and 100 inclusive. If [maxPercent] + /// is also set, then must be less than [maxPercent]. + /// [maxPercent] if set must be between 0 and 100 inclusive. + factory MarginSpec.fromPercent({int minPercent, int maxPercent}) { + // Percent must be within 0 to 100 + assert(minPercent == null || (minPercent >= 0 && minPercent <= 100)); + assert(maxPercent == null || (maxPercent >= 0 && maxPercent <= 100)); + // Min must be less than or equal to max. + // Can be equal to enforce strict percentage. + if (minPercent != null && maxPercent != null) { + assert(minPercent <= maxPercent); + } + + return new MarginSpec._internal(null, null, minPercent, maxPercent); + } + + /// Get the min pixels, given the [totalPixels]. + int getMinPixels(int totalPixels) { + if (_minPixel != null) { + assert(_minPixel < totalPixels); + return _minPixel; + } else if (_minPercent != null) { + return (totalPixels * (_minPercent / 100)).round(); + } else { + return 0; + } + } + + /// Get the max pixels, given the [totalPixels]. + int getMaxPixels(int totalPixels) { + if (_maxPixel != null) { + assert(_maxPixel < totalPixels); + return _maxPixel; + } else if (_maxPercent != null) { + return (totalPixels * (_maxPercent / 100)).round(); + } else { + return totalPixels; + } + } +} diff --git a/charts_common/lib/src/chart/layout/layout_manager.dart b/charts_common/lib/src/chart/layout/layout_manager.dart new file mode 100644 index 000000000..683a6e614 --- /dev/null +++ b/charts_common/lib/src/chart/layout/layout_manager.dart @@ -0,0 +1,50 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point, Rectangle; + +import 'layout_view.dart' show LayoutView; + +abstract class LayoutManager { + /// Adds a view to be managed by the LayoutManager. + void addView(LayoutView view); + + /// Removes a view previously added to the LayoutManager. + /// No-op if it wasn't there to begin with. + void removeView(LayoutView view); + + /// Returns true if view is already attached. + bool isAttached(LayoutView view); + + /// Walk through the child views and determine their desired sizes storing + /// off the information for layout. + void measure(int width, int height); + + /// Walk through the child views and set their bounds from the perspective + /// of the canvas origin. + void layout(int width, int height); + + /// Returns the bounds of the drawArea. Must be called after layout(). + Rectangle get drawAreaBounds; + + /// Returns whether or not [point] is within the draw area bounds. + bool withinDrawArea(Point point); + + /// Walk through the child views and apply the function passed in. + void applyToViews(void apply(LayoutView view)); + + /// Return the child views in the order that they should be painted. + List get paintOrderedViews; +} diff --git a/charts_common/lib/src/chart/layout/layout_manager_impl.dart b/charts_common/lib/src/chart/layout/layout_manager_impl.dart new file mode 100644 index 000000000..f6e94688a --- /dev/null +++ b/charts_common/lib/src/chart/layout/layout_manager_impl.dart @@ -0,0 +1,289 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show required; +import 'dart:math' show Point, Rectangle, max; +import 'layout_view.dart' show LayoutView, LayoutPosition; +import 'layout_config.dart' show LayoutConfig; +import 'layout_manager.dart'; +import 'layout_margin_strategy.dart'; + +/// Default Layout manager for [LayoutView]s. +class LayoutManagerImpl implements LayoutManager { + static const _minDrawWidth = 20; + static const _minDrawHeight = 20; + + // Allow [Layoutconfig] to be mutable so it can be modified without requiring + // a new copy of [DefaultLayoutManager] to be created. + LayoutConfig config; + + final _views = []; + + _MeasuredSizes _measurements; + + Rectangle _drawAreaBounds; + bool _drawAreaBoundsOutdated = true; + bool _viewsNeedSort = true; + + /// Create a new [LayoutManager]. + LayoutManagerImpl({LayoutConfig config}) + : this.config = config ?? new LayoutConfig(); + + /// Add one [LayoutView]. + void addView(LayoutView view) { + _views.add(view); + _drawAreaBoundsOutdated = true; + _viewsNeedSort = true; + } + + /// Remove one [LayoutView]. + void removeView(LayoutView view) { + if (_views.remove(view)) { + _drawAreaBoundsOutdated = true; + _viewsNeedSort = true; + } + } + + /// Returns true if [view] is already attached. + bool isAttached(LayoutView view) => _views.contains(view); + + /// Get all layout components in the order to be visited/drawn. + @override + List get paintOrderedViews { + if (_viewsNeedSort) { + // In place sort the views. + _views.sort((LayoutView v1, LayoutView v2) => v1 + .layoutConfig.positionOrder + .compareTo(v2.layoutConfig.positionOrder)); + _viewsNeedSort = false; + } + return _views; + } + + @override + Rectangle get drawAreaBounds { + assert(_drawAreaBoundsOutdated == false); + return _drawAreaBounds; + } + + @override + withinDrawArea(Point point) { + return _drawAreaBounds.containsPoint(point); + } + + /// Measure and layout with given [width] and [height]. + @override + void measure(int width, int height) { + var topViews = + _viewsForPositions(LayoutPosition.Top, LayoutPosition.FullTop); + var rightViews = + _viewsForPositions(LayoutPosition.Right, LayoutPosition.FullRight); + var bottomViews = + _viewsForPositions(LayoutPosition.Bottom, LayoutPosition.FullBottom); + var leftViews = + _viewsForPositions(LayoutPosition.Left, LayoutPosition.FullLeft); + + // Assume the full width and height of the chart is available when measuring + // for the first time but adjust the maximum if margin spec is set. + var measurements = _measure(width, height, + topViews: topViews, + rightViews: rightViews, + bottomViews: bottomViews, + leftViews: leftViews, + useMax: true); + + // Measure a second time but pass in the preferred width and height from + // the first measure cycle. + // Allow views to report a different size than the previously measured max. + final secondMeasurements = _measure(width, height, + topViews: topViews, + rightViews: rightViews, + bottomViews: bottomViews, + leftViews: leftViews, + previousMeasurements: measurements, + useMax: true); + + // If views need more space with the 2nd pass, perform a third pass. + if (measurements.leftWidth != secondMeasurements.leftWidth || + measurements.rightWidth != secondMeasurements.rightWidth || + measurements.topHeight != secondMeasurements.topHeight || + measurements.bottomHeight != secondMeasurements.bottomHeight) { + final thirdMeasurements = _measure(width, height, + topViews: topViews, + rightViews: rightViews, + bottomViews: bottomViews, + leftViews: leftViews, + previousMeasurements: secondMeasurements, + useMax: false); + + measurements = thirdMeasurements; + } else { + measurements = secondMeasurements; + } + + _measurements = measurements; + + // Draw area size. + // Set to a minimum size if there is not enough space for the draw area. + // Prevents the app from crashing by rendering overlapping content instead. + final drawAreaWidth = max( + _minDrawWidth, + (width - measurements.leftWidth - measurements.rightWidth), + ); + final drawAreaHeight = max( + _minDrawHeight, + (height - measurements.bottomHeight - measurements.topHeight), + ); + + // Bounds for the draw area. + _drawAreaBounds = new Rectangle(measurements.leftWidth, + measurements.topHeight, drawAreaWidth, drawAreaHeight); + _drawAreaBoundsOutdated = false; + } + + @override + void layout(int width, int height) { + var topViews = + _viewsForPositions(LayoutPosition.Top, LayoutPosition.FullTop); + var rightViews = + _viewsForPositions(LayoutPosition.Right, LayoutPosition.FullRight); + var bottomViews = + _viewsForPositions(LayoutPosition.Bottom, LayoutPosition.FullBottom); + var leftViews = + _viewsForPositions(LayoutPosition.Left, LayoutPosition.FullLeft); + var drawAreaViews = _viewsForPositions(LayoutPosition.DrawArea); + + final fullBounds = new Rectangle(0, 0, width, height); + + // Layout the margins. + new LeftMarginLayoutStrategy() + .layout(leftViews, _measurements.leftSizes, fullBounds, drawAreaBounds); + new RightMarginLayoutStrategy().layout( + rightViews, _measurements.rightSizes, fullBounds, drawAreaBounds); + new BottomMarginLayoutStrategy().layout( + bottomViews, _measurements.bottomSizes, fullBounds, drawAreaBounds); + new TopMarginLayoutStrategy() + .layout(topViews, _measurements.topSizes, fullBounds, drawAreaBounds); + + // Layout the drawArea. + drawAreaViews.forEach( + (LayoutView view) => view.layout(_drawAreaBounds, _drawAreaBounds)); + } + + Iterable _viewsForPositions(LayoutPosition p1, + [LayoutPosition p2]) { + return paintOrderedViews.where((LayoutView view) => + (view.layoutConfig.position == p1 || + (p2 != null && view.layoutConfig.position == p2))); + } + + /// Measure and return size measurements. + /// [width] full width of chart + /// [height] full height of chart + _MeasuredSizes _measure( + int width, + int height, { + Iterable topViews, + Iterable rightViews, + Iterable bottomViews, + Iterable leftViews, + _MeasuredSizes previousMeasurements, + @required bool useMax, + }) { + final maxLeftWidth = config.leftSpec.getMaxPixels(width); + final maxRightWidth = config.rightSpec.getMaxPixels(width); + final maxBottomHeight = config.bottomSpec.getMaxPixels(height); + final maxTopHeight = config.topSpec.getMaxPixels(height); + + // Assume the full width and height of the chart is available when measuring + // for the first time but adjust the maximum if margin spec is set. + var leftWidth = previousMeasurements?.leftWidth ?? maxLeftWidth; + var rightWidth = previousMeasurements?.rightWidth ?? maxRightWidth; + var bottomHeight = previousMeasurements?.bottomHeight ?? maxBottomHeight; + var topHeight = previousMeasurements?.topHeight ?? maxTopHeight; + + // Only adjust the height if we have previous measurements. + final adjustedHeight = (previousMeasurements != null) + ? height - bottomHeight - topHeight + : height; + + var leftSizes = new LeftMarginLayoutStrategy().measure(leftViews, + maxWidth: useMax ? maxLeftWidth : leftWidth, + height: adjustedHeight, + fullHeight: height); + + leftWidth = max(leftSizes.total, config.leftSpec.getMinPixels(width)); + + var rightSizes = new RightMarginLayoutStrategy().measure(rightViews, + maxWidth: useMax ? maxRightWidth : rightWidth, + height: adjustedHeight, + fullHeight: height); + rightWidth = max(rightSizes.total, config.rightSpec.getMinPixels(width)); + + final adjustedWidth = width - leftWidth - rightWidth; + + var bottomSizes = new BottomMarginLayoutStrategy().measure(bottomViews, + maxHeight: useMax ? maxBottomHeight : bottomHeight, + width: adjustedWidth, + fullWidth: width); + bottomHeight = max(bottomSizes.total, config.topSpec.getMinPixels(height)); + + var topSizes = new TopMarginLayoutStrategy().measure(topViews, + maxHeight: useMax ? maxTopHeight : topHeight, + width: adjustedWidth, + fullWidth: width); + topHeight = max(topSizes.total, config.topSpec.getMinPixels(height)); + + return new _MeasuredSizes( + leftWidth: leftWidth, + leftSizes: leftSizes, + rightWidth: rightWidth, + rightSizes: rightSizes, + topHeight: topHeight, + topSizes: topSizes, + bottomHeight: bottomHeight, + bottomSizes: bottomSizes); + } + + @override + void applyToViews(void apply(LayoutView view)) { + _views.forEach((view) => apply(view)); + } +} + +/// Helper class that stores measured width and height during measure cycles. +class _MeasuredSizes { + final int leftWidth; + final SizeList leftSizes; + + final int rightWidth; + final SizeList rightSizes; + + final int topHeight; + final SizeList topSizes; + + final int bottomHeight; + final SizeList bottomSizes; + + _MeasuredSizes( + {this.leftWidth, + this.leftSizes, + this.rightWidth, + this.rightSizes, + this.topHeight, + this.topSizes, + this.bottomHeight, + this.bottomSizes}); +} diff --git a/charts_common/lib/src/chart/layout/layout_margin_strategy.dart b/charts_common/lib/src/chart/layout/layout_margin_strategy.dart new file mode 100644 index 000000000..9baab1972 --- /dev/null +++ b/charts_common/lib/src/chart/layout/layout_margin_strategy.dart @@ -0,0 +1,273 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:meta/meta.dart'; +import 'layout_view.dart'; + +class SizeList { + final _sizes = []; + int _total = 0; + + operator [](i) => _sizes[i]; + + int get total => _total; + + int get length => _sizes.length; + + void add(size) { + _sizes.add(size); + _total += size; + } + + void adjust(int index, int amount) { + _sizes[index] += amount; + _total += amount; + } +} + +class _DesiredViewSizes { + final preferredSizes = new SizeList(); + final minimumSizes = new SizeList(); + + void add(int preferred, int minimum) { + preferredSizes.add(preferred); + minimumSizes.add(minimum); + } + + void adjustedTo(maxSize) { + if (maxSize < preferredSizes.total) { + int delta = preferredSizes.total - maxSize; + for (int i = preferredSizes.length - 1; i >= 0; i--) { + int viewAvailablePx = preferredSizes[i] - minimumSizes[i]; + + if (viewAvailablePx < delta) { + // We need even more than this one view can give up, so assign the + // minimum to the view and adjust totals. + preferredSizes.adjust(i, -viewAvailablePx); + delta -= viewAvailablePx; + } else { + // We can adjust this view to account for the delta. + preferredSizes.adjust(i, -delta); + return; + } + } + } + } +} + +/// A strategy for calculating size of vertical margins (RIGHT & LEFT). +abstract class VerticalMarginStrategy { + SizeList measure(Iterable views, + {@required int maxWidth, + @required int height, + @required int fullHeight}) { + final measuredWidths = new _DesiredViewSizes(); + int remainingWidth = maxWidth; + + views.forEach((LayoutView view) { + final params = view.layoutConfig; + final viewMargin = params.viewMargin; + + final availableHeight = + (params.isFullPosition ? fullHeight : height) - viewMargin.height; + + // Measure with all available space, minus the buffer. + remainingWidth = remainingWidth - viewMargin.width; + maxWidth -= viewMargin.width; + + var size = ViewMeasuredSizes.zero; + // Don't ask component to measure if both measurements are 0. + // + // Measure still needs to be called even when one dimension has a size of + // zero because if the component is an axis, the axis needs to still + // recalculate ticks even if it is not to be shown. + if (remainingWidth > 0 || availableHeight > 0) { + size = view.measure(remainingWidth, availableHeight); + remainingWidth -= size.preferredWidth; + } + + measuredWidths.add(size.preferredWidth, size.minWidth); + }); + + measuredWidths.adjustedTo(maxWidth); + return measuredWidths.preferredSizes; + } + + void layout(List views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds); +} + +/// A strategy for calculating size and bounds of left margins. +class LeftMarginLayoutStrategy extends VerticalMarginStrategy { + @override + void layout(Iterable views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds) { + var prevBoundsRight = drawAreaBounds.left; + + int i = 0; + views.forEach((LayoutView view) { + final params = view.layoutConfig; + + final width = measuredSizes[i]; + final left = prevBoundsRight - params.viewMargin.rightPx - width; + final height = + (params.isFullPosition ? fullBounds.height : drawAreaBounds.height) - + params.viewMargin.height; + final top = params.viewMargin.topPx + + (params.isFullPosition ? fullBounds.top : drawAreaBounds.top); + + // Update the remaining bounds. + prevBoundsRight = left - params.viewMargin.leftPx; + + // Layout this component. + view.layout(new Rectangle(left, top, width, height), drawAreaBounds); + + i++; + }); + } +} + +/// A strategy for calculating size and bounds of right margins. +class RightMarginLayoutStrategy extends VerticalMarginStrategy { + @override + void layout(Iterable views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds) { + var prevBoundsLeft = drawAreaBounds.right; + + int i = 0; + views.forEach((LayoutView view) { + final params = view.layoutConfig; + + final width = measuredSizes[i]; + final left = prevBoundsLeft + params.viewMargin.leftPx; + final height = + (params.isFullPosition ? fullBounds.height : drawAreaBounds.height) - + params.viewMargin.height; + final top = params.viewMargin.topPx + + (params.isFullPosition ? fullBounds.top : drawAreaBounds.top); + + // Update the remaining bounds. + prevBoundsLeft = left + width + params.viewMargin.rightPx; + + // Layout this component. + view.layout(new Rectangle(left, top, width, height), drawAreaBounds); + + i++; + }); + } +} + +/// A strategy for calculating size of horizontal margins (TOP & BOTTOM). +abstract class HorizontalMarginStrategy { + SizeList measure(Iterable views, + {@required int maxHeight, @required int width, @required int fullWidth}) { + final measuredHeights = new _DesiredViewSizes(); + int remainingHeight = maxHeight; + + views.forEach((LayoutView view) { + final params = view.layoutConfig; + final viewMargin = params.viewMargin; + + final availableWidth = + (params.isFullPosition ? fullWidth : width) - viewMargin.width; + + // Measure with all available space, minus the buffer. + remainingHeight = remainingHeight - viewMargin.height; + maxHeight -= viewMargin.height; + + var size = ViewMeasuredSizes.zero; + // Don't ask component to measure if both measurements are 0. + // + // Measure still needs to be called even when one dimension has a size of + // zero because if the component is an axis, the axis needs to still + // recalculate ticks even if it is not to be shown. + if (remainingHeight > 0 || availableWidth > 0) { + size = view.measure(availableWidth, remainingHeight); + remainingHeight -= size.preferredHeight; + } + + measuredHeights.add(size.preferredHeight, size.minHeight); + }); + + measuredHeights.adjustedTo(maxHeight); + return measuredHeights.preferredSizes; + } + + void layout(Iterable views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds); +} + +/// A strategy for calculating size and bounds of top margins. +class TopMarginLayoutStrategy extends HorizontalMarginStrategy { + @override + void layout(Iterable views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds) { + var prevBoundsBottom = drawAreaBounds.top; + + int i = 0; + views.forEach((LayoutView view) { + final params = view.layoutConfig; + + final height = measuredSizes[i]; + final top = prevBoundsBottom - height - params.viewMargin.bottomPx; + + final width = + (params.isFullPosition ? fullBounds.width : drawAreaBounds.width) - + params.viewMargin.width; + final left = params.viewMargin.leftPx + + (params.isFullPosition ? fullBounds.left : drawAreaBounds.left); + + // Update the remaining bounds. + prevBoundsBottom = top - params.viewMargin.topPx; + + // Layout this component. + view.layout(new Rectangle(left, top, width, height), drawAreaBounds); + + i++; + }); + } +} + +/// A strategy for calculating size and bounds of bottom margins. +class BottomMarginLayoutStrategy extends HorizontalMarginStrategy { + @override + void layout(Iterable views, SizeList measuredSizes, + Rectangle fullBounds, Rectangle drawAreaBounds) { + var prevBoundsTop = drawAreaBounds.bottom; + + int i = 0; + views.forEach((LayoutView view) { + final params = view.layoutConfig; + + final height = measuredSizes[i]; + final top = prevBoundsTop + params.viewMargin.topPx; + + final width = + (params.isFullPosition ? fullBounds.width : drawAreaBounds.width) - + params.viewMargin.width; + final left = params.viewMargin.leftPx + + (params.isFullPosition ? fullBounds.left : drawAreaBounds.left); + + // Update the remaining bounds. + prevBoundsTop = top + height + params.viewMargin.bottomPx; + + // Layout this component. + view.layout(new Rectangle(left, top, width, height), drawAreaBounds); + + i++; + }); + } +} diff --git a/charts_common/lib/src/chart/layout/layout_view.dart b/charts_common/lib/src/chart/layout/layout_view.dart new file mode 100644 index 000000000..7e2811f6e --- /dev/null +++ b/charts_common/lib/src/chart/layout/layout_view.dart @@ -0,0 +1,147 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:meta/meta.dart'; + +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../common/chart_canvas.dart' show ChartCanvas; + +/// Position of a [LayoutView]. +enum LayoutPosition { + Bottom, + FullBottom, + + Top, + FullTop, + + Left, + FullLeft, + + Right, + FullRight, + + DrawArea, +} + +/// A configuration for margin (empty space) around a layout child view. +class ViewMargin { + /// A [ViewMargin] with all zero px. + static const empty = + const ViewMargin(topPx: 0, bottomPx: 0, rightPx: 0, leftPx: 0); + + final int topPx; + final int bottomPx; + final int rightPx; + final int leftPx; + + const ViewMargin({int topPx, int bottomPx, int rightPx, int leftPx}) + : topPx = topPx ?? 0, + bottomPx = bottomPx ?? 0, + rightPx = rightPx ?? 0, + leftPx = leftPx ?? 0; + + /// Total width. + int get width => leftPx + rightPx; + + /// Total height. + int get height => topPx + bottomPx; +} + +/// Configuration of an [LayoutView]. +class LayoutViewConfig { + String id; + + /// The position of a [LayoutView] defining where to place the view. + LayoutPosition position; + + /// The order to place and draw the [LayoutView]. + /// + /// The smaller number is closer to the draw area. + int positionOrder; + + /// Defines the space around a layout component. + ViewMargin viewMargin; + + /// Creates new [LayoutParams]. + /// + /// [position] the [ComponentPosition] of this component. + /// [positionOrder] the smaller the p + LayoutViewConfig( + {@required this.position, + @required this.positionOrder, + ViewMargin viewMargin}) + : viewMargin = viewMargin ?? ViewMargin.empty; + + /// Returns true if it is a full position. + bool get isFullPosition => + position == LayoutPosition.FullBottom || + position == LayoutPosition.FullTop || + position == LayoutPosition.FullRight || + position == LayoutPosition.FullLeft; +} + +/// Size measurements of one component. +/// +/// The measurement is tight to the component, without adding [ComponentBuffer]. +class ViewMeasuredSizes { + /// All zeroes component size. + static const zero = const ViewMeasuredSizes( + preferredWidth: 0, preferredHeight: 0, minWidth: 0, minHeight: 0); + + final int preferredWidth; + final int preferredHeight; + final int minWidth; + final int minHeight; + + /// Create a new [ViewSizes]. + /// + /// [preferredWidth] the component's preferred width. + /// [preferredHeight] the component's preferred width. + /// [minWidth] the component's minimum width. If not set, default to 0. + /// [minHeight] the component's minimum height. If not set, default to 0. + const ViewMeasuredSizes( + {@required int preferredWidth, + @required int preferredHeight, + int minWidth, + int minHeight}) + : preferredWidth = preferredWidth, + preferredHeight = preferredHeight, + minWidth = minWidth ?? 0, + minHeight = minHeight ?? 0; +} + +/// A component that measures its size and accepts bounds to complete layout. +abstract class LayoutView { + GraphicsFactory get graphicsFactory; + + set graphicsFactory(GraphicsFactory value); + + /// Layout params for this component. + LayoutViewConfig get layoutConfig; + + /// Measure and return the size of this component. + /// + /// This measurement is without the [ComponentBuffer], which is added by the + /// layout manager. + ViewMeasuredSizes measure(int maxWidth, int maxHeight); + + /// Layout this component. + void layout(Rectangle componentBounds, Rectangle drawAreaBounds); + + void paint(ChartCanvas canvas, double animationPercent); + + Rectangle get componentBounds; +} diff --git a/charts_common/lib/src/chart/line/line_chart.dart b/charts_common/lib/src/chart/line/line_chart.dart new file mode 100644 index 000000000..41496faf0 --- /dev/null +++ b/charts_common/lib/src/chart/line/line_chart.dart @@ -0,0 +1,30 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../line/line_renderer.dart' show LineRenderer; +import '../cartesian/cartesian_chart.dart' show NumericCartesianChart; +import '../common/series_renderer.dart' show SeriesRenderer; +import '../layout/layout_config.dart' show LayoutConfig; + +class LineChart extends NumericCartesianChart { + LineChart({bool vertical, LayoutConfig layoutConfig}) + : super(vertical: vertical, layoutConfig: layoutConfig); + + @override + SeriesRenderer makeDefaultRenderer() { + return new LineRenderer() + ..rendererId = SeriesRenderer.defaultRendererId; + } +} diff --git a/charts_common/lib/src/chart/line/line_renderer.dart b/charts_common/lib/src/chart/line/line_renderer.dart new file mode 100644 index 000000000..71d6c9a79 --- /dev/null +++ b/charts_common/lib/src/chart/line/line_renderer.dart @@ -0,0 +1,431 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:collection' show LinkedHashMap; +import 'dart:math' show Point; + +import 'package:meta/meta.dart' show required; + +import '../cartesian/axis/axis.dart' + show ImmutableAxis, domainAxisKey, measureAxisKey; +import '../cartesian/cartesian_renderer.dart' show BaseCartesianRenderer; +import '../common/chart_canvas.dart' show ChartCanvas, getAnimatedColor; +import '../common/datum_details.dart' show DatumDetails; +import '../common/processed_series.dart' show ImmutableSeries, MutableSeries; +import '../../common/color.dart' show Color; +import '../../common/symbol_renderer.dart' show SymbolRenderer; +import '../../data/series.dart' show AttributeKey; +import 'line_renderer_config.dart' show LineRendererConfig; + +const lineElementsKey = + const AttributeKey>('LineRenderer.elements'); + +class LineRenderer extends BaseCartesianRenderer { + final LineRendererConfig config; + + /// Store a map of series drawn on the chart, mapped by series name. + /// + /// [LinkedHashMap] is used to render the series on the canvas in the same + /// order as the data was given to the chart. + final _seriesLineMap = new LinkedHashMap>>(); + + // Store a list of lines that exist in the series data. + // + // This list will be used to remove any [_AnimatedLine] that were rendered in + // previous draw cycles, but no longer have a corresponding datum in the new + // data. + final _currentKeys = []; + + LineRenderer({String rendererId, LineRendererConfig config}) + : this.config = config ?? new LineRendererConfig(), + super(rendererId: rendererId ?? 'line', layoutPositionOrder: 10); + + @override + SymbolRenderer get symbolRenderer => config.symbolRenderer; + + void preprocessSeries(List> seriesList) { + assignMissingColors(seriesList, emptyCategoryUsesSinglePalette: false); + + seriesList.forEach((MutableSeries series) { + var elements = <_LineRendererElement>[]; + + var strokeWidthPxFn = series.strokeWidthPxFn; + + if (series.dashPattern == null) { + series.dashPattern = config.dashPattern; + } + + var details = new _LineRendererElement(); + + // Since we do not currently support segments for lines, just grab the + // stroke width from the first datum for each series. + // + // TODO: Support stroke width per datum with line segments. + if (series.data.length > 0 && strokeWidthPxFn != null) { + T datum = series.data[0]; + details.strokeWidthPx = strokeWidthPxFn(datum, 0).toDouble(); + } else { + details.strokeWidthPx = this.config.strokeWidthPx; + } + + elements.add(details); + + series.setAttr(lineElementsKey, elements); + }); + } + + void update( + List> seriesList, bool isAnimatingThisDraw) { + _currentKeys.clear(); + + seriesList.forEach((ImmutableSeries series) { + var domainAxis = series.getAttr(domainAxisKey) as ImmutableAxis; + var domainFn = series.domainFn; + var measureAxis = series.getAttr(measureAxisKey) as ImmutableAxis; + var measureFn = series.measureFn; + var measureOffsetFn = series.measureOffsetFn; + var colorFn = series.colorFn; + var lineKey = series.id; + var dashPattern = series.dashPattern; + + // TODO: Handle changes in data color, pattern, or other + // attributes by configuring a list of line segments for the series, + // instead of just one big line that contains all the points. + var lineList = _seriesLineMap.putIfAbsent(lineKey, () => []); + + var elementsList = series.getAttr(lineElementsKey); + _LineRendererElement details = elementsList[0]; + + // If we already have a AnimatingLine for that index, use it. + _AnimatedLine animatingLine; + if (lineList.length > 0) { + animatingLine = lineList[0]; + } else { + // Create a new line and have it animate in from axis. + var pointList = <_DatumPoint>[]; + Color color; + for (var index = 0; index < series.data.length; index++) { + T datum = series.data[index]; + + pointList.add(_getPoint(datum, domainFn(datum, index), series, + domainAxis, 0.0, 0.0, measureAxis)); + + color = colorFn(series.data[index], index); + } + + animatingLine = new _AnimatedLine( + key: lineKey, overlaySeries: series.overlaySeries) + ..setNewTarget(new _LineRendererElement() + ..color = color + ..points = pointList + ..dashPattern = dashPattern + ..measureAxisPosition = measureAxis.getLocation(0.0) + ..strokeWidthPx = details.strokeWidthPx); + + lineList.add(animatingLine); + } + + // Create a new line using the final point locations. + var pointList = <_DatumPoint>[]; + Color color; + for (var index = 0; index < series.data.length; index++) { + T datum = series.data[index]; + + pointList.add(_getPoint( + datum, + domainFn(datum, index), + series, + domainAxis, + measureFn(datum, index), + measureOffsetFn(datum, index), + measureAxis)); + + color = colorFn(series.data[index], index); + } + + // Update the set of lines that still exist in the series data. + _currentKeys.add(lineKey); + + // Get the lineElement we are going to setup. + final lineElement = new _LineRendererElement() + ..points = pointList + ..color = color + ..dashPattern = dashPattern + ..measureAxisPosition = measureAxis.getLocation(0.0) + ..strokeWidthPx = details.strokeWidthPx; + + animatingLine.setNewTarget(lineElement); + }); + + // Animate out lines that don't exist anymore. + _seriesLineMap.forEach((String key, List<_AnimatedLine> lines) { + for (var line in lines) { + if (_currentKeys.contains(line.key) != true) { + line.animateOut(); + } + } + }); + } + + void paint(ChartCanvas canvas, double animationPercent) { + // Clean up the lines that no longer exist. + if (animationPercent == 1.0) { + final keysToRemove = []; + + _seriesLineMap.forEach((String key, List<_AnimatedLine> lines) { + lines.removeWhere((_AnimatedLine line) => line.animatingOut); + + if (lines.isEmpty) { + keysToRemove.add(key); + } + }); + + keysToRemove.forEach((String key) => _seriesLineMap.remove(key)); + } + + _seriesLineMap.forEach((String key, List<_AnimatedLine> lines) { + lines + .map<_LineRendererElement>( + (_AnimatedLine animatingLine) => + animatingLine.getCurrentLine(animationPercent)) + .forEach((_LineRendererElement line) { + canvas.drawLine( + dashPattern: line.dashPattern, + points: line.points, + stroke: line.color, + strokeWidthPx: line.strokeWidthPx); + }); + }); + } + + _DatumPoint _getPoint( + T datum, + D domainValue, + ImmutableSeries series, + ImmutableAxis domainAxis, + num measureValue, + num measureOffsetValue, + ImmutableAxis measureAxis) { + final domainPosition = domainAxis.getLocation(domainValue); + + final measurePosition = + measureAxis.getLocation(measureValue + measureOffsetValue); + + return new _DatumPoint( + datum: datum, + domain: domainValue, + series: series, + x: domainPosition, + y: measurePosition); + } + + @override + List> getNearestDatumDetailPerSeries( + Point chartPoint) { + final nearest = >[]; + + // Was it even in the drawArea? + if (!componentBounds.containsPoint(chartPoint)) { + return nearest; + } + + _seriesLineMap.values.forEach((List<_AnimatedLine> seriesSegments) { + _DatumPoint nearestPoint; + double nearestDomainDistance = 10000.0; + double nearestMeasureDistance = 10000.0; + + seriesSegments.forEach((_AnimatedLine segment) { + if (segment.overlaySeries) { + return; + } + + segment._currentLine.points.forEach((Point p) { + // Don't look at points not in the drawArea. + if (p.x < componentBounds.left || p.x > componentBounds.right) { + return; + } + + final domainDistance = (p.x - chartPoint.x).abs(); + final measureDistance = (p.y - chartPoint.y).abs(); + if ((domainDistance < nearestDomainDistance) || + ((domainDistance == nearestDomainDistance && + measureDistance < nearestMeasureDistance))) { + nearestPoint = p; + nearestDomainDistance = domainDistance; + nearestMeasureDistance = measureDistance; + } + }); + }); + + // Found a point, add it to the list. + if (nearestPoint != null) { + nearest.add(new DatumDetails( + datum: nearestPoint.datum, + domain: nearestPoint.domain, + series: nearestPoint.series, + domainDistance: nearestDomainDistance, + measureDistance: nearestMeasureDistance)); + } + }); + + // Note: the details are already sorted by domain & measure distance in + // base chart. + + return nearest; + } +} + +class _DatumPoint extends Point { + final T datum; + final D domain; + final ImmutableSeries series; + + _DatumPoint({this.datum, this.domain, this.series, double x, double y}) + : super(x, y); + + factory _DatumPoint.from(_DatumPoint other, [double x, double y]) { + return new _DatumPoint( + datum: other.datum, + domain: other.domain, + series: other.series, + x: x ?? other.x, + y: y ?? other.y); + } +} + +class _LineRendererElement { + List<_DatumPoint> points; + Color color; + List dashPattern; + double measureAxisPosition; + double strokeWidthPx; + + _LineRendererElement clone() { + return new _LineRendererElement() + ..points = this.points + ..color = this.color + ..dashPattern = this.dashPattern + ..measureAxisPosition = this.measureAxisPosition + ..strokeWidthPx = this.strokeWidthPx; + } + + void updateAnimationPercent(_LineRendererElement previous, + _LineRendererElement target, double animationPercent) { + Point lastPoint; + + int pointIndex; + for (pointIndex = 0; pointIndex < target.points.length; pointIndex++) { + var targetPoint = target.points[pointIndex]; + + // If we have more points than the previous line, animate in the new point + // by starting its measure position at the last known official point. + // TODO: Can this be done in setNewTarget instead? + _DatumPoint previousPoint; + if (previous.points.length - 1 >= pointIndex) { + previousPoint = previous.points[pointIndex]; + lastPoint = previousPoint; + } else { + previousPoint = + new _DatumPoint.from(targetPoint, targetPoint.x, lastPoint.y); + } + + var x = ((targetPoint.x - previousPoint.x) * animationPercent) + + previousPoint.x; + + var y = ((targetPoint.y - previousPoint.y) * animationPercent) + + previousPoint.y; + + if (points.length - 1 >= pointIndex) { + points[pointIndex] = new _DatumPoint.from(targetPoint, x, y); + } else { + points.add(new _DatumPoint.from(targetPoint, x, y)); + } + } + + // Removing extra points that don't exist anymore. + if (pointIndex < points.length) { + points.removeRange(pointIndex, points.length); + } + + color = getAnimatedColor(previous.color, target.color, animationPercent); + + strokeWidthPx = + (((target.strokeWidthPx - previous.strokeWidthPx) * animationPercent) + + previous.strokeWidthPx); + } +} + +class _AnimatedLine { + final String key; + final bool overlaySeries; + + _LineRendererElement _previousLine; + _LineRendererElement _targetLine; + _LineRendererElement _currentLine; + + // Flag indicating whether this line is being animated out of the chart. + bool animatingOut = false; + + _AnimatedLine({@required this.key, @required this.overlaySeries}); + + /// Animates a line that was removed from the series out of the view. + /// + /// This should be called in place of "setNewTarget" for lines that represent + /// data that has been removed from the series. + /// + /// Animates the height of the line down to the measure axis position + /// (position of 0). + void animateOut() { + var newTarget = _currentLine.clone(); + + // Set the target measure value to the axis position for all points. + var newPoints = <_DatumPoint>[]; + for (var index = 0; index < newTarget.points.length; index++) { + var targetPoint = newTarget.points[index]; + + newPoints.add(new _DatumPoint.from(targetPoint, targetPoint.x, + newTarget.measureAxisPosition.roundToDouble())); + } + + newTarget.points = newPoints; + + // Animate the stroke width to 0 so that we don't get a lingering line after + // animation is done. + newTarget.strokeWidthPx = 0.0; + + setNewTarget(newTarget); + animatingOut = true; + } + + void setNewTarget(_LineRendererElement newTarget) { + animatingOut = false; + _currentLine ??= newTarget.clone(); + _previousLine = _currentLine; + _targetLine = newTarget; + } + + _LineRendererElement getCurrentLine(double animationPercent) { + if (animationPercent == 1.0 || _previousLine == null) { + _currentLine = _targetLine; + _previousLine = _targetLine; + return _currentLine; + } + + _currentLine.updateAnimationPercent( + _previousLine, _targetLine, animationPercent); + + return _currentLine; + } +} diff --git a/charts_common/lib/src/chart/line/line_renderer_config.dart b/charts_common/lib/src/chart/line/line_renderer_config.dart new file mode 100644 index 000000000..ed9adc264 --- /dev/null +++ b/charts_common/lib/src/chart/line/line_renderer_config.dart @@ -0,0 +1,47 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../common/symbol_renderer.dart'; +import '../layout/layout_view.dart' show LayoutViewConfig; +import '../common/series_renderer_config.dart' + show RendererAttributes, SeriesRendererConfig; +import 'line_renderer.dart' show LineRenderer; + +/// Configuration for a line renderer. +class LineRendererConfig extends LayoutViewConfig + implements SeriesRendererConfig { + final String customRendererId; + + final SymbolRenderer symbolRenderer; + + final rendererAttributes = new RendererAttributes(); + + /// Stroke width of the line. + final double strokeWidthPx; + + /// Dash pattern for the line. + final List dashPattern; + + LineRendererConfig( + {this.customRendererId, + this.strokeWidthPx = 2.0, + this.dashPattern, + this.symbolRenderer}); + + @override + LineRenderer build() { + return new LineRenderer(config: this, rendererId: customRendererId); + } +} diff --git a/charts_common/lib/src/chart/time_series/time_series_chart.dart b/charts_common/lib/src/chart/time_series/time_series_chart.dart new file mode 100644 index 000000000..4cb4cd1cf --- /dev/null +++ b/charts_common/lib/src/chart/time_series/time_series_chart.dart @@ -0,0 +1,52 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../cartesian/cartesian_chart.dart' show CartesianChart; +import '../cartesian/axis/time/date_time_axis.dart' show DateTimeAxis; +import '../cartesian/axis/draw_strategy/small_tick_draw_strategy.dart' + show SmallTickRendererSpec; +import '../common/chart_context.dart' show ChartContext; +import '../common/series_renderer.dart' show SeriesRenderer; +import '../layout/layout_config.dart' show LayoutConfig; +import '../line/line_renderer.dart' show LineRenderer; +import '../../common/graphics_factory.dart' show GraphicsFactory; +import '../../common/date_time_factory.dart' + show DateTimeFactory, LocalDateTimeFactory; + +class TimeSeriesChart extends CartesianChart { + final DateTimeAxis domainAxis; + final DateTimeFactory dateTimeFactory; + + TimeSeriesChart( + {bool vertical, + LayoutConfig layoutConfig, + this.dateTimeFactory = const LocalDateTimeFactory()}) + : domainAxis = new DateTimeAxis(dateTimeFactory), + super(vertical: vertical, layoutConfig: layoutConfig); + + void init(ChartContext context, GraphicsFactory graphicsFactory) { + super.init(context, graphicsFactory); + domainAxis.context = context; + domainAxis.tickDrawStrategy = new SmallTickRendererSpec() + .createDrawStrategy(context, graphicsFactory); + addView(domainAxis); + } + + @override + SeriesRenderer makeDefaultRenderer() { + return new LineRenderer() + ..rendererId = SeriesRenderer.defaultRendererId; + } +} diff --git a/charts_common/lib/src/common/color.dart b/charts_common/lib/src/common/color.dart new file mode 100644 index 000000000..c34254dce --- /dev/null +++ b/charts_common/lib/src/common/color.dart @@ -0,0 +1,91 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +@immutable +class Color { + static const black = const Color(r: 0, g: 0, b: 0); + static const white = const Color(r: 255, g: 255, b: 255); + static const transparent = const Color(r: 0, g: 0, b: 0, a: 0); + + static const _darkerPercentOfOrig = 0.7; + static const _lighterPercentOfOrig = 0.1; + + final int r; + final int g; + final int b; + final int a; + + final Color _darker; + final Color _lighter; + + const Color( + {this.r, this.g, this.b, this.a = 255, Color darker, Color lighter}) + : _darker = darker, + _lighter = lighter; + + Color.fromOther({Color color, Color darker, Color lighter}) + : r = color.r, + g = color.g, + b = color.b, + a = color.a, + _darker = darker ?? color.darker, + _lighter = lighter ?? color.lighter; + + /// Construct the color from a hex code string, of the format #RRGGBB. + factory Color.fromHex({String code}) { + var str = code.substring(1, 7); + var bigint = int.parse(str, radix: 16); + final r = (bigint >> 16) & 255; + final g = (bigint >> 8) & 255; + final b = bigint & 255; + final a = 255; + return new Color(r: r, g: g, b: b, a: a); + } + + Color get darker => + _darker ?? + new Color( + r: (r * _darkerPercentOfOrig).round(), + g: (g * _darkerPercentOfOrig).round(), + b: (b * _darkerPercentOfOrig).round(), + a: a); + + Color get lighter => + _lighter ?? + new Color( + r: r + ((255 - r) * _lighterPercentOfOrig).round(), + g: g + ((255 - g) * _lighterPercentOfOrig).round(), + b: b + ((255 - b) * _lighterPercentOfOrig).round(), + a: a); + + @override + bool operator ==(Object other) => + other is Color && + other.r == r && + other.g == g && + other.b == b && + other.a == a; + + @override + int get hashCode { + var hashcode = r.hashCode; + hashcode = hashcode * 37 + g.hashCode; + hashcode = hashcode * 37 + b.hashCode; + hashcode = hashcode * 37 + a.hashCode; + return hashcode; + } +} diff --git a/charts_common/lib/src/common/date_time_factory.dart b/charts_common/lib/src/common/date_time_factory.dart new file mode 100644 index 000000000..0fdc52d59 --- /dev/null +++ b/charts_common/lib/src/common/date_time_factory.dart @@ -0,0 +1,98 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:intl/intl.dart' show DateFormat; + +/// Interface for factory that creates [DateTime] and [DateFormat]. +/// +/// This allows for creating of locale specific date time and date format. +abstract class DateTimeFactory { + // TODO: Per cbraun@, we need to allow setting the timezone that + // is used globally (along with other settings like which day the week starts + // on. Use DateTimeFactory - either return a local DateTime or a UTC date time + // based on the setting. + + // TODO: We need to incorporate the time zoned calendar here + // because Dart DateTime doesn't do this. TZDateTime implements DateTime, so + // we can use DateTime as the interface. + DateTime createDateTimeFromMilliSecondsSinceEpoch(int millisecondsSinceEpoch); + + DateTime createDateTime(int year, + [int month = 1, + int day = 1, + int hour = 0, + int minute = 0, + int second = 0, + int millisecond = 0, + int microsecond = 0]); + + /// Returns a [DateFormat]. + DateFormat createDateFormat(String pattern); +} + +/// A local time [DateTimeFactory]. +class LocalDateTimeFactory implements DateTimeFactory { + const LocalDateTimeFactory(); + + DateTime createDateTimeFromMilliSecondsSinceEpoch( + int millisecondsSinceEpoch) { + return new DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch); + } + + DateTime createDateTime(int year, + [int month = 1, + int day = 1, + int hour = 0, + int minute = 0, + int second = 0, + int millisecond = 0, + int microsecond = 0]) { + return new DateTime( + year, month, day, hour, minute, second, millisecond, microsecond); + } + + /// Returns a [DateFormat]. + DateFormat createDateFormat(String pattern) { + return new DateFormat(pattern); + } +} + +/// An UTC time [DateTimeFactory]. +class UTCDateTimeFactory implements DateTimeFactory { + const UTCDateTimeFactory(); + + DateTime createDateTimeFromMilliSecondsSinceEpoch( + int millisecondsSinceEpoch) { + return new DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch, + isUtc: true); + } + + DateTime createDateTime(int year, + [int month = 1, + int day = 1, + int hour = 0, + int minute = 0, + int second = 0, + int millisecond = 0, + int microsecond = 0]) { + return new DateTime.utc( + year, month, day, hour, minute, second, millisecond, microsecond); + } + + /// Returns a [DateFormat]. + DateFormat createDateFormat(String pattern) { + return new DateFormat(pattern); + } +} diff --git a/charts_common/lib/src/common/gesture_listener.dart b/charts_common/lib/src/common/gesture_listener.dart new file mode 100644 index 000000000..049205b32 --- /dev/null +++ b/charts_common/lib/src/common/gesture_listener.dart @@ -0,0 +1,104 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; + +/// Listener to touch gestures. +/// +/// [GestureListeners] can override only the gestures it is interested in. +/// +/// Each gesture returns true if the event is consumed or false if it should +/// continue to alert other listeners. +class GestureListener { + static final GestureCancelCallback defaultTapCancel = () {}; + static final GestureSinglePointCallback defaultTapTest = (_) => false; + + /// Called before all gestures (except onHover) as a preliminary test to + /// see who is interested in an event. + /// + /// All listeners that return true will get the next gesture event. + /// + /// Any listener that returns false will only get the next gesture event if + /// no one returned true. + /// + /// This is useful for figuring out who is claiming a gesture event. + /// Example: SelectNearest returns true for onTapTest if the point is within + /// the drawArea. SeriesLegend returns true for onTapTest if the point is + /// within the legend. If the tap occurs in either of those places the + /// corresponding listener. If the tap occurs outside of both targets, then + /// both will be given the event so they can deselect everything in the + /// selection model. + /// + /// Defaults to function that returns false allowing other listeners to preempt. + final GestureSinglePointCallback onTapTest; + + /// Called if onTapTest was previously called, but listener is being preempted. + final GestureCancelCallback onTapCancel; + + /// Called after the tap event has been going on for a period of time (500ms) + /// without moving much (20px). + /// The onTap or onDragStart gestures can still trigger after this gesture. + final GestureSinglePointCallback onLongPress; + + /// Called on tap up if not dragging. + final GestureSinglePointCallback onTap; + + /// Called when a mouse hovers over the chart. (No tap event). + final GestureSinglePointCallback onHover; + + /// Called when the tap event has moved beyond a threshold indicating that + /// the user is dragging. + /// + /// This will only be called once per drag gesture independent of how many + /// touches are going on until the last touch is complete. onDragUpdate is + /// called as touches move updating the scale as determined by the first + /// two points. onDragEnd is called when the last touch event lifts and the + /// velocity is calculated from the final movement. + /// + /// onDragStart, onDragUpdate, and onDragEnd are also called for mouse wheel + /// with the scale and point updated given the WheelEvent (deltaY updates the + /// scale, deltaX updates the event point/pans). + /// + /// TODO: Add a "discrete" flag that tells drag listeners whether + /// they should be expecting a series of continuous updates, or one large + /// update. This will mostly be used to control whether we animate the chart + /// between onDragUpdate calls. + /// + /// TODO: Investigate low performance of chart rendering from + /// flutter when animation is enabled and we pinch to zoom on the chart. + final GestureDragStartCallback onDragStart; + final GestureDragUpdateCallback onDragUpdate; + final GestureDragEndCallback onDragEnd; + + GestureListener( + {GestureSinglePointCallback onTapTest, + GestureCancelCallback onTapCancel, + this.onLongPress, + this.onTap, + this.onHover, + this.onDragStart, + this.onDragUpdate, + this.onDragEnd}) + : this.onTapTest = onTapTest ?? defaultTapTest, + this.onTapCancel = onTapCancel ?? defaultTapCancel; +} + +typedef GestureCancelCallback(); +typedef bool GestureSinglePointCallback(Point localPosition); + +typedef bool GestureDragStartCallback(Point localPosition); +typedef GestureDragUpdateCallback(Point localPosition, double scale); +typedef GestureDragEndCallback( + Point localPosition, double scale, double pixelsPerSec); diff --git a/charts_common/lib/src/common/graphics_factory.dart b/charts_common/lib/src/common/graphics_factory.dart new file mode 100644 index 000000000..7bce54a56 --- /dev/null +++ b/charts_common/lib/src/common/graphics_factory.dart @@ -0,0 +1,29 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'line_style.dart' show LineStyle; +import 'text_element.dart' show TextElement; +import 'text_style.dart' show TextStyle; + +/// Interface to native platform graphics functions. +abstract class GraphicsFactory { + LineStyle createLinePaint(); + + /// Returns a [TextStyle] object. + TextStyle createTextPaint(); + + /// Returns a text element from [text] and [style]. + TextElement createTextElement(String text); +} diff --git a/charts_common/lib/src/common/line_style.dart b/charts_common/lib/src/common/line_style.dart new file mode 100644 index 000000000..f72ebdfa5 --- /dev/null +++ b/charts_common/lib/src/common/line_style.dart @@ -0,0 +1,21 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'paint_style.dart' show PaintStyle; + +abstract class LineStyle extends PaintStyle { + int get strokeWidth; + set strokeWidth(int strokeWidth); +} diff --git a/charts_common/lib/src/common/material_palette.dart b/charts_common/lib/src/common/material_palette.dart new file mode 100644 index 000000000..4102521cf --- /dev/null +++ b/charts_common/lib/src/common/material_palette.dart @@ -0,0 +1,231 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'color.dart' show Color; +import 'palette.dart' show Palette; + +/// A canonical palette of colors from material.io. +/// +/// @link https://material.io/guidelines/style/color.html#color-color-palette +class MaterialPalette { + static const black = const Color(r: 0, g: 0, b: 0); + static const white = const Color(r: 255, g: 255, b: 255); + + static Palette get blue => const MaterialBlue(); + static Palette get red => const MaterialRed(); + static Palette get yellow => const MaterialYellow(); + static Palette get green => const MaterialGreen(); + static Palette get purple => const MaterialPurple(); + static Palette get cyan => const MaterialCyan(); + static Palette get deepOrange => const MaterialDeepOrange(); + static Palette get lime => const MaterialLime(); + static Palette get indigo => const MaterialIndigo(); + static Palette get pink => const MaterialPink(); + static Palette get teal => const MaterialTeal(); + static MaterialGray get gray => const MaterialGray(); + + static List getOrderedPalettes(int count) { + final orderedPalettes = []; + if (orderedPalettes.length < count) { + orderedPalettes.add(blue); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(red); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(yellow); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(green); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(purple); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(cyan); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(deepOrange); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(lime); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(indigo); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(pink); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(teal); + } + return orderedPalettes; + } +} + +class MaterialBlue extends Palette { + static const _shade200 = const Color(r: 0x90, g: 0xCA, b: 0xF9); //#90CAF9 + static const _shade500 = const Color( + r: 0x21, g: 0x96, b: 0xF3, darker: _shade700, lighter: _shade200); + static const _shade700 = const Color(r: 0x19, g: 0x76, b: 0xD2); //#1976D2 + + const MaterialBlue(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialRed extends Palette { + static const _shade200 = const Color(r: 0xEF, g: 0x9A, b: 0x9A); //#EF9A9A + static const _shade700 = const Color(r: 0xD3, g: 0x2F, b: 0x2F); //#D32F2F + static const _shade500 = const Color( + r: 0xF4, g: 0x43, b: 0x36, darker: _shade700, lighter: _shade200); + + const MaterialRed(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialYellow extends Palette { + static const _shade200 = const Color(r: 0xFF, g: 0xF5, b: 0x9D); //#FFF59D + static const _shade700 = const Color(r: 0xFB, g: 0xC0, b: 0x2D); //#FBC02D + static const _shade500 = const Color( + r: 0xFF, g: 0xEB, b: 0x3B, darker: _shade700, lighter: _shade200); + + const MaterialYellow(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialGreen extends Palette { + static const _shade200 = const Color(r: 0xA5, g: 0xD6, b: 0xA7); //#A5D6A7 + static const _shade700 = const Color(r: 0x38, g: 0x8E, b: 0x3C); //#388E3C; + static const _shade500 = const Color( + r: 0x4C, g: 0xAF, b: 0x50, darker: _shade700, lighter: _shade200); + + const MaterialGreen(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialPurple extends Palette { + static const _shade200 = const Color(r: 0xCE, g: 0x93, b: 0xD8); //#CE93D8 + static const _shade700 = const Color(r: 0x7B, g: 0x1F, b: 0xA2); //#7B1FA2 + static const _shade500 = const Color( + r: 0x9C, g: 0x27, b: 0xB0, darker: _shade700, lighter: _shade200); + + const MaterialPurple(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialCyan extends Palette { + static const _shade200 = const Color(r: 0x80, g: 0xDE, b: 0xEA); //#80DEEA + static const _shade700 = const Color(r: 0x00, g: 0x97, b: 0xA7); //#0097A7 + static const _shade500 = const Color( + r: 0x00, g: 0xBC, b: 0xD4, darker: _shade700, lighter: _shade200); + + const MaterialCyan(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialDeepOrange extends Palette { + static const _shade200 = const Color(r: 0xFF, g: 0xAB, b: 0x91); //#FFAB91 + static const _shade700 = const Color(r: 0xE6, g: 0x4A, b: 0x19); //#E64A19 + static const _shade500 = const Color( + r: 0xFF, g: 0x57, b: 0x22, darker: _shade700, lighter: _shade200); + + const MaterialDeepOrange(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialLime extends Palette { + static const _shade200 = const Color(r: 0xE6, g: 0xEE, b: 0x9C); //#E6EE9C + static const _shade700 = const Color(r: 0xAF, g: 0xB4, b: 0x2B); //#AFB42B + static const _shade500 = const Color( + r: 0xCD, g: 0xDC, b: 0x39, darker: _shade700, lighter: _shade200); + + const MaterialLime(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialIndigo extends Palette { + static const _shade200 = const Color(r: 0x9F, g: 0xA8, b: 0xDA); //#9FA8DA + static const _shade700 = const Color(r: 0x30, g: 0x3F, b: 0x9F); //#303F9F + static const _shade500 = const Color( + r: 0x3F, g: 0x51, b: 0xB5, darker: _shade700, lighter: _shade200); + + const MaterialIndigo(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialPink extends Palette { + static const _shade200 = const Color(r: 0xF4, g: 0x8F, b: 0xB1); //#F48FB1 + static const _shade700 = const Color(r: 0xC2, g: 0x18, b: 0x5B); //#C2185B + static const _shade500 = const Color( + r: 0xE9, g: 0x1E, b: 0x63, darker: _shade700, lighter: _shade200); + + const MaterialPink(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialTeal extends Palette { + static const _shade200 = const Color(r: 0x80, g: 0xCB, b: 0xC4); //#80CBC4 + static const _shade700 = const Color(r: 0x00, g: 0x79, b: 0x6B); //#00796B + static const _shade500 = const Color( + r: 0x00, g: 0x96, b: 0x88, darker: _shade700, lighter: _shade200); + + const MaterialTeal(); + + @override + Color get shadeDefault => _shade500; +} + +class MaterialGray extends Palette { + static const _shade200 = const Color(r: 0xEE, g: 0xEE, b: 0xEE); //#EEEEEE + static const _shade700 = const Color(r: 0x61, g: 0x61, b: 0x61); //#616161 + static const _shade500 = const Color( + r: 0x9E, g: 0x9E, b: 0x9E, darker: _shade700, lighter: _shade200); + + const MaterialGray(); + + @override + Color get shadeDefault => _shade500; + + Color get shade50 => const Color(r: 0xFA, g: 0xFA, b: 0xFA); //#FAFAFA + Color get shade100 => const Color(r: 0xF5, g: 0xF5, b: 0xF5); //#F5F5F5 + Color get shade200 => _shade200; + Color get shade300 => const Color(r: 0xE0, g: 0xE0, b: 0xE0); //#E0E0E0 + Color get shade400 => const Color(r: 0xBD, g: 0xBD, b: 0xBD); //#BDBDBD + Color get shade500 => _shade500; + Color get shade600 => const Color(r: 0x75, g: 0x75, b: 0x75); //#757575 + Color get shade700 => _shade700; + Color get shade800 => const Color(r: 0x42, g: 0x42, b: 0x42); //#424242 + Color get shade900 => const Color(r: 0x21, g: 0x21, b: 0xA1); //#212121 +} diff --git a/charts_common/lib/src/common/paint_style.dart b/charts_common/lib/src/common/paint_style.dart new file mode 100644 index 000000000..047f3e929 --- /dev/null +++ b/charts_common/lib/src/common/paint_style.dart @@ -0,0 +1,23 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'color.dart'; + +/// Style properties of a paintable object. +abstract class PaintStyle { + Color get color; + + set color(Color value); +} diff --git a/charts_common/lib/src/common/palette.dart b/charts_common/lib/src/common/palette.dart new file mode 100644 index 000000000..a85e6eb06 --- /dev/null +++ b/charts_common/lib/src/common/palette.dart @@ -0,0 +1,58 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'color.dart' show Color; + +/// A color palette. +abstract class Palette { + const Palette(); + + /// The default shade. + Color get shadeDefault; + + /// Returns a list of colors for this color palette. + List makeShades(int colorCnt) { + final colors = [shadeDefault]; + + // If we need more than 2 colors, then [unselected] collides with one of the + // generated colors. Otherwise divide the space between the top color + // and white in half. + final lighterColor = colorCnt < 3 + ? shadeDefault.lighter + : _getSteppedColor(shadeDefault, (colorCnt * 2) - 1, colorCnt * 2); + + // Divide the space between 255 and c500 evenly according to the colorCnt. + for (int i = 1; i < colorCnt; i++) { + colors.add(_getSteppedColor(shadeDefault, i, colorCnt, + darker: shadeDefault.darker, lighter: lighterColor)); + } + + colors.add(new Color.fromOther(color: shadeDefault, lighter: lighterColor)); + return colors; + } + + Color _getSteppedColor(Color color, int index, int steps, + {Color darker, Color lighter}) { + final fraction = index / steps; + return new Color( + r: color.r + ((255 - color.r) * fraction).round(), + g: color.g + ((255 - color.g) * fraction).round(), + b: color.b + ((255 - color.b) * fraction).round(), + a: color.a + ((255 - color.a) * fraction).round(), + darker: darker, + lighter: lighter, + ); + } +} diff --git a/charts_common/lib/src/common/performance.dart b/charts_common/lib/src/common/performance.dart new file mode 100644 index 000000000..3706ce1c6 --- /dev/null +++ b/charts_common/lib/src/common/performance.dart @@ -0,0 +1,21 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +typedef PerformanceCallback(String tag); + +class Performance { + static PerformanceCallback time = (_) {}; + static PerformanceCallback timeEnd = (_) {}; +} diff --git a/charts_common/lib/src/common/proxy_gesture_listener.dart b/charts_common/lib/src/common/proxy_gesture_listener.dart new file mode 100644 index 000000000..3086b2f83 --- /dev/null +++ b/charts_common/lib/src/common/proxy_gesture_listener.dart @@ -0,0 +1,120 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; + +import 'gesture_listener.dart' show GestureListener; + +/// Listens to all gestures and proxies to child listeners. +class ProxyGestureListener { + final listeners = []; + var activeListeners = []; + + bool onTapTest(Point localPosition) { + var localListeners = new List.from(listeners); + + activeListeners.clear(); + + var previouslyClaimed = false; + localListeners.forEach((GestureListener listener) { + var claimed = listener.onTapTest(localPosition); + if (claimed && !previouslyClaimed) { + // Cancel any already added non-claiming listeners now that someone is + // claiming it. + activeListeners = _cancel(all: activeListeners, keep: [listener]); + previouslyClaimed = true; + } else if (claimed || !previouslyClaimed) { + activeListeners.add(listener); + } + }); + + return previouslyClaimed; + } + + bool onLongPress(Point localPosition) { + // Walk through listeners stopping at the first handled listener. + final claimingListener = activeListeners.firstWhere( + (GestureListener listener) => + listener.onLongPress != null && listener.onLongPress(localPosition), + orElse: () => null); + + // If someone claims the long press, then cancel everyone else. + if (claimingListener != null) { + activeListeners = _cancel(all: activeListeners, keep: [claimingListener]); + return true; + } + return false; + } + + bool onTap(Point localPosition) { + // Walk through listeners stopping at the first handled listener. + final claimingListener = activeListeners.firstWhere( + (GestureListener listener) => + listener.onTap != null && listener.onTap(localPosition), + orElse: () => null); + + // If someone claims the tap, then cancel everyone else. + // This should hopefully be rare, like for drilling. + if (claimingListener != null) { + activeListeners = _cancel(all: activeListeners, keep: [claimingListener]); + return true; + } + return false; + } + + bool onHover(Point localPosition) { + // Cancel any previously active long lived gestures. + activeListeners = []; + + // Walk through listeners stopping at the first handled listener. + return listeners.any((GestureListener listener) => + listener.onHover != null && listener.onHover(localPosition)); + } + + bool onDragStart(Point localPosition) { + // Walk through listeners stopping at the first handled listener. + final claimingListener = activeListeners.firstWhere( + (GestureListener listener) => + listener.onDragStart != null && listener.onDragStart(localPosition), + orElse: () => null); + + if (claimingListener != null) { + activeListeners = _cancel(all: activeListeners, keep: [claimingListener]); + return true; + } + return false; + } + + bool onDragUpdate(Point localPosition, double scale) { + return activeListeners.any((GestureListener listener) => + listener.onDragUpdate(localPosition, scale)); + } + + bool onDragEnd( + Point localPosition, double scale, double pixelsPerSecond) { + return activeListeners.any((GestureListener listener) => + listener.onDragEnd(localPosition, scale, pixelsPerSecond)); + } + + List _cancel( + {List all, List keep}) { + all.forEach((GestureListener listener) { + if (!keep.contains(listener)) { + listener.onTapCancel(); + } + }); + return keep; + } +} diff --git a/charts_common/lib/src/common/quantum_palette.dart b/charts_common/lib/src/common/quantum_palette.dart new file mode 100644 index 000000000..8effc07a8 --- /dev/null +++ b/charts_common/lib/src/common/quantum_palette.dart @@ -0,0 +1,231 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'color.dart' show Color; +import 'palette.dart' show Palette; + +/// A canonical palette of charting colors from quantum specs. +/// +/// @link go/charting +class QuantumPalette { + static const black = const Color(r: 0, g: 0, b: 0); + static const white = const Color(r: 255, g: 255, b: 255); + + static Palette get googleBlue => const GoogleBluePalette(); + static Palette get googleRed => const GoogleRedPalette(); + static Palette get googleYellow => const GoogleYellowPalette(); + static Palette get googleGreen => const GoogleGreenPalette(); + static Palette get purple => const PurplePalette(); + static Palette get cyan => const CyanPalette(); + static Palette get deepOrange => const DeepOrangePalette(); + static Palette get lime => const LimePalette(); + static Palette get indigo => const IndigoPalette(); + static Palette get pink => const PinkPalette(); + static Palette get teal => const TealPalette(); + static GrayPalette get gray => const GrayPalette(); + + static List getOrderedPalettes(int count) { + final orderedPalettes = []; + if (orderedPalettes.length < count) { + orderedPalettes.add(googleBlue); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(googleRed); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(googleYellow); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(googleGreen); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(purple); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(cyan); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(deepOrange); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(lime); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(indigo); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(pink); + } + if (orderedPalettes.length < count) { + orderedPalettes.add(teal); + } + return orderedPalettes; + } +} + +class GoogleBluePalette extends Palette { + static const _shade100 = const Color(r: 0xC6, g: 0xDA, b: 0xFC); // #C6DAFC + static const _shade800 = const Color(r: 0x2A, g: 0x56, b: 0xC6); // #2A56C6 + static const _shade500 = const Color( + r: 0x42, g: 0x85, b: 0xF4, darker: _shade800, lighter: _shade100); + + const GoogleBluePalette(); + + @override + Color get shadeDefault => _shade500; +} + +class GoogleRedPalette extends Palette { + static const _shade100 = const Color(r: 0xF4, g: 0xC7, b: 0xC3); // #F4C7C3 + static const _shade900 = const Color(r: 0xA5, g: 0x27, b: 0x14); // #A52714 + static const _shade500 = const Color( + r: 0xDB, g: 0x44, b: 0x37, darker: _shade900, lighter: _shade100); + + const GoogleRedPalette(); + + @override + Color get shadeDefault => _shade500; +} + +class GoogleYellowPalette extends Palette { + static const _shade100 = const Color(r: 0xFC, g: 0xE8, b: 0xB2); // #FCE8B2 + static const _shade800 = const Color(r: 0xEE, g: 0x81, b: 0x00); // #EE8100 + static const _shade500 = const Color( + r: 0xF4, g: 0xB4, b: 0x00, darker: _shade800, lighter: _shade100); + + const GoogleYellowPalette(); + + @override + Color get shadeDefault => _shade500; +} + +class GoogleGreenPalette extends Palette { + static const _shade100 = const Color(r: 0xB7, g: 0xE1, b: 0xCD); // #B7E1CD + static const _shade700 = const Color(r: 0x0B, g: 0x80, b: 0x43); // #0B8043 + static const _shade500 = const Color( + r: 0x0F, g: 0x9D, b: 0x58, darker: _shade700, lighter: _shade100); + + const GoogleGreenPalette(); + + @override + Color get shadeDefault => _shade500; +} + +class PurplePalette extends Palette { + static const _shade100 = const Color(r: 0xE1, g: 0xBE, b: 0xE7); // #E1BEE7 + static const _shade800 = const Color(r: 0x6A, g: 0x1B, b: 0x9A); // #6A1B9A + static const _shade400 = const Color( + r: 0xAB, g: 0xB4, b: 0xBC, darker: _shade800, lighter: _shade100); + + const PurplePalette(); + + @override + Color get shadeDefault => _shade400; +} + +class CyanPalette extends Palette { + static const _shade100 = const Color(r: 0xB2, g: 0xEB, b: 0xF2); // #B2EBF2 + static const _shade800 = const Color(r: 0x00, g: 0x83, b: 0x8F); // #00838F + static const _shade600 = const Color( + r: 0x00, g: 0xAC, b: 0xC1, darker: _shade800, lighter: _shade100); + + const CyanPalette(); + + @override + Color get shadeDefault => _shade600; +} + +class DeepOrangePalette extends Palette { + static const _shade100 = const Color(r: 0xFF, g: 0xCC, b: 0xBC); // #FFCCBC + static const _shade700 = const Color(r: 0xE6, g: 0x4A, b: 0x19); // #E64A19 + static const _shade400 = const Color( + r: 0xFF, g: 0x70, b: 0x43, darker: _shade700, lighter: _shade100); + + const DeepOrangePalette(); + + @override + Color get shadeDefault => _shade400; +} + +class LimePalette extends Palette { + static const _shade100 = const Color(r: 0xF0, g: 0xF4, b: 0xC3); // #F0F4C3 + static const _shade900 = const Color(r: 0x82, g: 0x77, b: 0x17); // #827717 + static const _shade800 = const Color( + r: 0x9E, g: 0x9D, b: 0x24, darker: _shade900, lighter: _shade100); + + const LimePalette(); + + @override + Color get shadeDefault => _shade800; +} + +class IndigoPalette extends Palette { + static const _shade100 = const Color(r: 0xC5, g: 0xCA, b: 0xE9); // #C5CAE9 + static const _shade600 = const Color(r: 0x39, g: 0x49, b: 0xAB); // #3949AB + static const _shade400 = const Color( + r: 0x5C, g: 0x6B, b: 0xC0, darker: _shade600, lighter: _shade100); + + const IndigoPalette(); + + @override + Color get shadeDefault => _shade400; +} + +class PinkPalette extends Palette { + static const _shade100 = const Color(r: 0xF8, g: 0xBB, b: 0xD0); // #F8BBD0 + static const _shade500 = const Color(r: 0xE9, g: 0x1E, b: 0x63); // #E91E63 + static const _shade300 = const Color( + r: 0xF0, g: 0x62, b: 0x92, darker: _shade500, lighter: _shade100); + + const PinkPalette(); + + @override + Color get shadeDefault => _shade300; +} + +class TealPalette extends Palette { + static const _shade100 = const Color(r: 0xB2, g: 0xDF, b: 0xDB); // #B2DFDB + static const _shade900 = const Color(r: 0x00, g: 0x4B, b: 0x40); // #004D40 + static const _shade700 = const Color( + r: 0x00, g: 0x79, b: 0x6B, darker: _shade100, lighter: _shade900); + + const TealPalette(); + + @override + Color get shadeDefault => _shade700; +} + +class GrayPalette extends Palette { + static const _shade100 = const Color(r: 0xF5, g: 0xF5, b: 0xF5); + static const _shade800 = const Color(r: 0x42, g: 0x42, b: 0x42); + static const _shade500 = const Color( + r: 0x9E, g: 0x9E, b: 0x9E, darker: _shade800, lighter: _shade100); + + const GrayPalette(); + + @override + Color get shadeDefault => _shade500; + + Color get shade50 => const Color(r: 0xFA, g: 0xFA, b: 0xFA); + Color get shade100 => _shade100; + Color get shade200 => const Color(r: 0xEE, g: 0xEE, b: 0xEE); + Color get shade300 => const Color(r: 0xE0, g: 0xE0, b: 0xE0); + Color get shade400 => const Color(r: 0xBD, g: 0xBD, b: 0xBD); + Color get shade500 => _shade500; + Color get shade600 => const Color(r: 0x75, g: 0x75, b: 0x75); + Color get shade700 => const Color(r: 0x61, g: 0x61, b: 0x61); + Color get shade800 => _shade800; + Color get shade900 => const Color(r: 0x21, g: 0x21, b: 0x21); +} diff --git a/charts_common/lib/src/common/rtl_spec.dart b/charts_common/lib/src/common/rtl_spec.dart new file mode 100644 index 000000000..64ae1252a --- /dev/null +++ b/charts_common/lib/src/common/rtl_spec.dart @@ -0,0 +1,45 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Defines the behavior of the chart if it is RTL. +class RTLSpec { + /// Creates [RTLSpec]. If no parameters are specified, the defaults are used. + const RTLSpec({ + this.axisPosition: AxisPosition.reversed, + }); + + /// The positions for primary and secondary measure axis. + final AxisPosition axisPosition; +} + +/// Determines the positions of the primary and secondary measure axis. +/// +/// [normal] Vertically rendered charts will have the primary measure axis on +/// the left and secondary measure axis on the right. Domain axis is on the left +/// and the domain output range starts from the left and grows to the right. +/// Horizontally rendered charts will have the primary measure axis on the +/// bottom and secondary measure axis on the right. Measure output range starts +/// from the left and grows to the right. +/// +/// [reversed] Vertically rendered charts will have the primary measure axis on +/// the right and secondary measure axis on the left. Domain axis is on the +/// right and domain values grows from the right to the left. Horizontally +/// rendered charts will have the primary measure axis on the top and secondary +/// measure axis on the left. Measure output range is flipped and grows from the +/// right to the left. +enum AxisPosition { + normal, + reversed, +} diff --git a/charts_common/lib/src/common/style/material_style.dart b/charts_common/lib/src/common/style/material_style.dart new file mode 100644 index 000000000..c4ee0ec5b --- /dev/null +++ b/charts_common/lib/src/common/style/material_style.dart @@ -0,0 +1,72 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../chart/cartesian/axis/spec/axis_spec.dart' show LineStyleSpec; +import '../color.dart' show Color; +import '../graphics_factory.dart' show GraphicsFactory; +import '../line_style.dart' show LineStyle; +import '../material_palette.dart' show MaterialPalette; +import '../palette.dart' show Palette; +import 'style.dart' show Style; + +class MaterialStyle implements Style { + const MaterialStyle(); + + @override + Color get black => MaterialPalette.black; + + @override + Color get white => MaterialPalette.white; + + @override + List getOrderedPalettes(int count) => + MaterialPalette.getOrderedPalettes(count); + + @override + LineStyle createAxisLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? MaterialPalette.gray.shadeDefault + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + LineStyle createTickLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? MaterialPalette.gray.shadeDefault + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + int get tickLength => 3; + + @override + Color get tickColor => MaterialPalette.gray.shade800; + + @override + LineStyle createGridlineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? MaterialPalette.gray.shade300 + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + Color get rangeAnnotationColor => MaterialPalette.gray.shade100; + + @override + Color get linePointHighlighterColor => MaterialPalette.gray.shade600; +} diff --git a/charts_common/lib/src/common/style/quantum_style.dart b/charts_common/lib/src/common/style/quantum_style.dart new file mode 100644 index 000000000..f7f93ccfd --- /dev/null +++ b/charts_common/lib/src/common/style/quantum_style.dart @@ -0,0 +1,72 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../chart/cartesian/axis/spec/axis_spec.dart' show LineStyleSpec; +import '../color.dart' show Color; +import '../graphics_factory.dart' show GraphicsFactory; +import '../line_style.dart' show LineStyle; +import '../quantum_palette.dart' show QuantumPalette; +import '../palette.dart' show Palette; +import 'style.dart' show Style; + +class QuantumStyle implements Style { + const QuantumStyle(); + + @override + Color get black => QuantumPalette.black; + + @override + Color get white => QuantumPalette.white; + + @override + List getOrderedPalettes(int count) => + QuantumPalette.getOrderedPalettes(count); + + @override + LineStyle createAxisLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? QuantumPalette.gray.shadeDefault + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + LineStyle createTickLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? QuantumPalette.gray.shadeDefault + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + int get tickLength => 3; + + @override + Color get tickColor => QuantumPalette.gray.shade800; + + @override + LineStyle createGridlineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec) { + return graphicsFactory.createLinePaint() + ..color = spec?.color ?? QuantumPalette.gray.shade300 + ..strokeWidth = spec?.thickness ?? 1; + } + + @override + Color get rangeAnnotationColor => QuantumPalette.gray.shade100; + + @override + Color get linePointHighlighterColor => QuantumPalette.gray.shade600; +} diff --git a/charts_common/lib/src/common/style/style.dart b/charts_common/lib/src/common/style/style.dart new file mode 100644 index 000000000..38a4b8b49 --- /dev/null +++ b/charts_common/lib/src/common/style/style.dart @@ -0,0 +1,66 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import '../../chart/cartesian/axis/spec/axis_spec.dart' show LineStyleSpec; +import '../color.dart' show Color; +import '../graphics_factory.dart' show GraphicsFactory; +import '../line_style.dart' show LineStyle; +import '../palette.dart'; + +// TODO: Implementation of style will change drastically, see bug +// for more details. This is an intermediate step in order to allow overriding +// the default style using style factory. + +/// A set of styling rules that determines the default look and feel of charts. +/// +/// Get or set the [Style] that is used for the app using [StyleFactory.style]. +abstract class Style { + Color get black; + Color get white; + + /// Gets list with [count] of palettes. + List getOrderedPalettes(int count); + + /// Creates [LineStyleSpec] for axis line from spec. + /// + /// Fill missing value(s) with default. + LineStyle createAxisLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec); + + /// Creates [LineStyleSpec] for tick lines from spec. + /// + /// Fill missing value(s) with default. + LineStyle createTickLineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec); + + /// Default tick length. + int get tickLength; + + /// Default tick color. + Color get tickColor; + + /// + /// Creates [LineStyle] for axis gridlines from spec. + /// + /// Fill missing value(s) with default. + LineStyle createGridlineStyle( + GraphicsFactory graphicsFactory, LineStyleSpec spec); + + /// Default color for [RangeAnnotation]. + Color get rangeAnnotationColor; + + /// Default color for [LinePointHighlighter]. + Color get linePointHighlighterColor; +} diff --git a/charts_common/lib/src/common/style/style_factory.dart b/charts_common/lib/src/common/style/style_factory.dart new file mode 100644 index 000000000..2f69a8444 --- /dev/null +++ b/charts_common/lib/src/common/style/style_factory.dart @@ -0,0 +1,32 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'style.dart' show Style; +import 'material_style.dart' show MaterialStyle; + +class StyleFactory { + static final StyleFactory _styleFactory = new StyleFactory._internal(); + + Style _style = const MaterialStyle(); + + /// The [Style] that is used for all the charts in this application. + static Style get style => _styleFactory._style; + + static set style(Style value) { + _styleFactory._style = value; + } + + StyleFactory._internal(); +} diff --git a/charts_common/lib/src/common/symbol_renderer.dart b/charts_common/lib/src/common/symbol_renderer.dart new file mode 100644 index 000000000..eccdd218c --- /dev/null +++ b/charts_common/lib/src/common/symbol_renderer.dart @@ -0,0 +1,53 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'color.dart' show Color; +import '../chart/common/chart_canvas.dart' show ChartCanvas; + +abstract class SymbolRenderer { + void paint(ChartCanvas canvas, Rectangle bounds, Color color); + + bool shouldRepaint(covariant SymbolRenderer oldRenderer); +} + +class RoundedRectSymbolRenderer extends SymbolRenderer { + final double radius; + + RoundedRectSymbolRenderer({double radius}) : radius = radius ?? 1.0; + + void paint(ChartCanvas canvas, Rectangle bounds, Color color) { + canvas.drawRRect(bounds, + fill: color, + stroke: color, + radius: radius, + roundTopLeft: true, + roundTopRight: true, + roundBottomRight: true, + roundBottomLeft: true); + } + + bool shouldRepaint(RoundedRectSymbolRenderer oldRenderer) { + return this != oldRenderer; + } + + @override + bool operator ==(Object other) { + return other is RoundedRectSymbolRenderer && other.radius == radius; + } + + @override + int get hashCode => radius.hashCode; +} diff --git a/charts_common/lib/src/common/text_element.dart b/charts_common/lib/src/common/text_element.dart new file mode 100644 index 000000000..b497851a5 --- /dev/null +++ b/charts_common/lib/src/common/text_element.dart @@ -0,0 +1,62 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'text_measurement.dart' show TextMeasurement; +import 'text_style.dart' show TextStyle; + +/// Interface for accessing text measurement and painter. +abstract class TextElement { + /// The [TextStyle] of this [TextElement]. + TextStyle get textStyle; + + set textStyle(TextStyle value); + + /// The max width of this [TextElement] during measure and layout. + /// + /// If the text exceeds maxWidth, the [maxWidthStrategy] is used. + int get maxWidth; + + set maxWidth(int value); + + /// The strategy to use if this [TextElement] exceeds the [maxWidth]. + MaxWidthStrategy get maxWidthStrategy; + + set maxWidthStrategy(MaxWidthStrategy maxWidthStrategy); + + // The text of this [TextElement]. + String get text; + + /// The [TextMeasurement] of this [TextElement] as an approximate of what + /// is actually printed. + /// + /// Will return the [maxWidth] if set and the actual text width is larger. + TextMeasurement get measurement; + + /// The direction to render the text relative to the coordinate. + TextDirection get textDirection; + set textDirection(TextDirection direction); +} + +enum TextDirection { + ltr, + rtl, + center, +} + +/// The strategy to use if a [TextElement] exceeds the [maxWidth]. +enum MaxWidthStrategy { + truncate, + ellipsize, +} diff --git a/charts_common/lib/src/common/text_measurement.dart b/charts_common/lib/src/common/text_measurement.dart new file mode 100644 index 000000000..fb419a05b --- /dev/null +++ b/charts_common/lib/src/common/text_measurement.dart @@ -0,0 +1,32 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// A measurement result for rendering text. +class TextMeasurement { + /// Rendered width of the text. + final double horizontalSliceWidth; + + /// Vertical slice is likely based off the rendered text. + /// + /// This means that 'mo' and 'My' will have different heights so do not use + /// this for centering vertical text. + final double verticalSliceWidth; + + /// Baseline of the text for text vertical alignment. + final double baseline; + + TextMeasurement( + {this.horizontalSliceWidth, this.verticalSliceWidth, this.baseline}); +} diff --git a/charts_common/lib/src/common/text_style.dart b/charts_common/lib/src/common/text_style.dart new file mode 100644 index 000000000..b88dbd1ac --- /dev/null +++ b/charts_common/lib/src/common/text_style.dart @@ -0,0 +1,25 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'paint_style.dart' show PaintStyle; + +/// Paint properties of a text. +abstract class TextStyle extends PaintStyle { + int get fontSize; + set fontSize(int value); + + String get fontFamily; + set fontFamily(String fontFamily); +} diff --git a/charts_common/lib/src/common/typed_registry.dart b/charts_common/lib/src/common/typed_registry.dart new file mode 100644 index 000000000..50ea4c542 --- /dev/null +++ b/charts_common/lib/src/common/typed_registry.dart @@ -0,0 +1,42 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +class TypedRegistry { + final Map _registry = {}; + + R getAttr(TypedKey key) { + return _registry[key] as R; + } + + void setAttr(TypedKey key, R value) { + _registry[key] = value; + } + + void mergeFrom(TypedRegistry other) { + _registry.addAll(other._registry); + } +} + +class TypedKey { + final String unique_key; + const TypedKey(this.unique_key); + + @override + int get hashCode => unique_key.hashCode; + + @override + bool operator ==(other) => + other is TypedKey && unique_key == other.unique_key; +} diff --git a/charts_common/lib/src/data/series.dart b/charts_common/lib/src/data/series.dart new file mode 100644 index 000000000..d9c3dd9bb --- /dev/null +++ b/charts_common/lib/src/data/series.dart @@ -0,0 +1,87 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart'; +import '../common/color.dart' show Color; +import '../common/typed_registry.dart' show TypedRegistry, TypedKey; +import '../chart/common/chart_canvas.dart' show FillPatternType; + +class Series { + final String id; + final String displayName; + final String seriesCategory; + final bool overlaySeries; + + final List data; + + final AccessorFn domainFn; + final AccessorFn measureFn; + final AccessorFn measureUpperBoundFn; + final AccessorFn measureLowerBoundFn; + final AccessorFn measureOffsetFn; + final AccessorFn colorFn; + final AccessorFn fillPatternFn; + final AccessorFn labelAccessorFn; + final AccessorFn radiusPxFn; + final AccessorFn strokeWidthPxFn; + + final List dashPattern; + + // TODO: should this be immutable as well? If not, should any of + // the non-required ones be final? + final SeriesAttributes attributes = new SeriesAttributes(); + + Series({ + @required this.id, + @required this.data, + @required this.domainFn, + @required this.measureFn, + this.displayName, + this.colorFn, + this.dashPattern, + this.fillPatternFn, + this.labelAccessorFn, + this.measureLowerBoundFn, + this.measureOffsetFn, + this.measureUpperBoundFn, + this.overlaySeries = false, + this.radiusPxFn, + this.seriesCategory, + this.strokeWidthPxFn, + }); + + void setAttribute(AttributeKey key, R value) { + this.attributes.setAttr(key, value); + } + + R getAttribute(AttributeKey key) { + return this.attributes.getAttr(key); + } +} + +/// Computed property on series. +/// +/// If the [index] argument is `null`, the accessor is asked to provide a +/// property of [series] as a whole. Accessors are not required to support +/// such usage. +/// +/// Otherwise, [index] must be a valid subscript into a list of `series.length`. +typedef R AccessorFn(T datum, int index); + +class AttributeKey extends TypedKey { + const AttributeKey(String uniqueKey) : super(uniqueKey); +} + +class SeriesAttributes extends TypedRegistry {} diff --git a/charts_common/pubspec.yaml b/charts_common/pubspec.yaml new file mode 100644 index 000000000..f44ec526e --- /dev/null +++ b/charts_common/pubspec.yaml @@ -0,0 +1,17 @@ +name: charts_common +version: 0.0.1 +description: A common library for charting packages. +author: Charts Team +homepage: https://github.com/google/charts + +environment: + sdk: '>=1.23.0 <2.0.0' + +dependencies: + collection: ^1.14.5 + intl: ^0.15.2 + meta: ^1.1.1 + +dev_dependencies: + mockito: 3.0.0-alpha + test: ^0.12.0 diff --git a/charts_common/test/chart/bar/bar_label_decorator_test.dart b/charts_common/test/chart/bar/bar_label_decorator_test.dart new file mode 100644 index 000000000..25a3ec97f --- /dev/null +++ b/charts_common/test/chart/bar/bar_label_decorator_test.dart @@ -0,0 +1,394 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:charts_common/src/chart/common/processed_series.dart' + show ImmutableSeries; +import 'package:charts_common/src/common/color.dart' show Color; +import 'package:charts_common/src/common/graphics_factory.dart' + show GraphicsFactory; +import 'package:charts_common/src/common/line_style.dart' show LineStyle; +import 'package:charts_common/src/common/text_element.dart' + show TextDirection, TextElement, MaxWidthStrategy; +import 'package:charts_common/src/common/text_measurement.dart' + show TextMeasurement; +import 'package:charts_common/src/common/text_style.dart' show TextStyle; +import 'package:charts_common/src/chart/bar/bar_renderer.dart' + show ImmutableBarRendererElement; +import 'package:charts_common/src/chart/cartesian/axis/spec/axis_spec.dart' + show TextStyleSpec; +import 'package:charts_common/src/chart/common/chart_canvas.dart' + show ChartCanvas; +import 'package:charts_common/src/chart/bar/bar_label_decorator.dart' + show BarLabelDecorator, BarLabelAnchor, BarLabelPosition; +import 'package:charts_common/src/data/series.dart' show AccessorFn; + +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockCanvas extends Mock implements ChartCanvas {} + +/// A fake [GraphicsFactory] that returns [FakeTextStyle] and [FakeTextElement]. +class FakeGraphicsFactory extends GraphicsFactory { + @override + TextStyle createTextPaint() => new FakeTextStyle(); + + @override + TextElement createTextElement(String text) => new FakeTextElement(text); + + @override + LineStyle createLinePaint() => new MockLinePaint(); +} + +/// Stores [TextStyle] properties for test to verify. +class FakeTextStyle implements TextStyle { + Color color; + int fontSize; + String fontFamily; +} + +/// Fake [TextElement] which returns text length as [horizontalSliceWidth]. +/// +/// Font size is returned for [verticalSliceWidth] and [baseline]. +class FakeTextElement implements TextElement { + final String text; + TextStyle textStyle; + int maxWidth; + MaxWidthStrategy maxWidthStrategy; + TextDirection textDirection; + + FakeTextElement(this.text); + + TextMeasurement get measurement => new TextMeasurement( + horizontalSliceWidth: text.length.toDouble(), + verticalSliceWidth: textStyle.fontSize.toDouble(), + baseline: textStyle.fontSize.toDouble()); +} + +class MockLinePaint extends Mock implements LineStyle {} + +class FakeBarRendererElement + implements ImmutableBarRendererElement { + final _series = new MockImmutableSeries(); + final AccessorFn labelAccessor; + final String datum; + final Rectangle bounds; + + FakeBarRendererElement(this.datum, this.bounds, this.labelAccessor) { + when(_series.labelAccessorFn).thenReturn(labelAccessor); + } + + ImmutableSeries get series => _series; +} + +class MockImmutableSeries extends Mock implements ImmutableSeries {} + +void main() { + ChartCanvas canvas; + GraphicsFactory graphicsFactory; + Rectangle drawBounds; + + setUpAll(() { + canvas = new MockCanvas(); + graphicsFactory = new FakeGraphicsFactory(); + drawBounds = new Rectangle(0, 0, 200, 100); + }); + + group('horizontal bar chart', () { + test('Paint labels with default settings', () { + final barElements = [ + // 'LabelA' and 'LabelB' both have lengths of 6. + // 'LabelB' would not fit inside the bar in auto setting because it has + // width of 5. + new FakeBarRendererElement( + 'A', new Rectangle(0, 20, 50, 20), (_, __) => 'LabelA'), + new FakeBarRendererElement( + 'B', new Rectangle(0, 70, 5, 20), (_, __) => 'LabelB') + ]; + final decorator = new BarLabelDecorator(); + + decorator.decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + // Draw text is called twice (once for each bar) and all 3 parameters were + // captured. Total parameters captured expected to be 6. + expect(captured, hasLength(6)); + // For bar 'A'. + expect(captured[0].maxWidth, equals(50 - decorator.labelPadding * 2)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[1], equals(decorator.labelPadding)); + expect(captured[2], + equals(30 - decorator.insideLabelStyleSpec.fontSize ~/ 2)); + // For bar 'B'. + expect( + captured[3].maxWidth, equals(200 - 5 - decorator.labelPadding * 2)); + expect(captured[3].textDirection, equals(TextDirection.ltr)); + expect(captured[4], equals(5 + decorator.labelPadding)); + expect(captured[5], + equals(80 - decorator.outsideLabelStyleSpec.fontSize ~/ 2)); + }); + + test('LabelPosition.auto paints inside bar if outside bar has less width', + () { + final barElements = [ + // 'LabelABC' would not fit inside the bar in auto setting because it + // has a width of 8. + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 6, 20), (_, __) => 'LabelABC'), + ]; + // Draw bounds with width of 10 means that space inside the bar is larger. + final smallDrawBounds = new Rectangle(0, 0, 10, 20); + + new BarLabelDecorator( + labelPadding: 0, // Turn off label padding for testing. + insideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: smallDrawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(6)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[1], equals(0)); + expect(captured[2], equals(5)); + }); + + test('LabelPosition.inside always paints inside the bar', () { + final barElements = [ + // 'LabelABC' would not fit inside the bar in auto setting because it + // has a width of 8. + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 6, 20), (_, __) => 'LabelABC'), + ]; + + new BarLabelDecorator( + labelPosition: BarLabelPosition.inside, + labelPadding: 0, // Turn off label padding for testing. + insideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(6)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[1], equals(0)); + expect(captured[2], equals(5)); + }); + + test('LabelPosition.outside always paints outside the bar', () { + final barElements = [ + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 10, 20), (_, __) => 'Label'), + ]; + + new BarLabelDecorator( + labelPosition: BarLabelPosition.outside, + labelPadding: 0, // Turn off label padding for testing. + outsideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(190)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[1], equals(10)); + expect(captured[2], equals(5)); + }); + + test('Inside and outside label styles are applied', () { + final barElements = [ + // 'LabelA' and 'LabelB' both have lengths of 6. + // 'LabelB' would not fit inside the bar in auto setting because it has + // width of 5. + new FakeBarRendererElement( + 'A', new Rectangle(0, 20, 50, 20), (_, __) => 'LabelA'), + new FakeBarRendererElement( + 'B', new Rectangle(0, 70, 5, 20), (_, __) => 'LabelB') + ]; + final insideColor = new Color(r: 0, g: 0, b: 0); + final outsideColor = new Color(r: 255, g: 255, b: 255); + final decorator = new BarLabelDecorator( + labelPadding: 0, + insideLabelStyleSpec: new TextStyleSpec( + fontSize: 10, fontFamily: 'insideFont', color: insideColor), + outsideLabelStyleSpec: new TextStyleSpec( + fontSize: 8, fontFamily: 'outsideFont', color: outsideColor)); + + decorator.decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + // Draw text is called twice (once for each bar) and all 3 parameters were + // captured. Total parameters captured expected to be 6. + expect(captured, hasLength(6)); + // For bar 'A'. + expect(captured[0].maxWidth, equals(50)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[0].textStyle.fontFamily, equals('insideFont')); + expect(captured[0].textStyle.color, equals(insideColor)); + expect(captured[1], equals(0)); + expect(captured[2], equals(30 - 5)); + // For bar 'B'. + expect(captured[3].maxWidth, equals(200 - 5)); + expect(captured[3].textDirection, equals(TextDirection.ltr)); + expect(captured[3].textStyle.fontFamily, equals('outsideFont')); + expect(captured[3].textStyle.color, equals(outsideColor)); + expect(captured[4], equals(5)); + expect(captured[5], equals(80 - 4)); + }); + + test('TextAnchor.end starts on the right most of bar', () { + final barElements = [ + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 10, 20), (_, __) => 'LabelA') + ]; + + new BarLabelDecorator( + labelAnchor: BarLabelAnchor.end, + labelPosition: BarLabelPosition.inside, + labelPadding: 0, // Turn off label padding for testing. + insideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(10)); + expect(captured[0].textDirection, equals(TextDirection.rtl)); + expect(captured[1], equals(10)); + expect(captured[2], equals(5)); + }); + + test('RTL TextAnchor.start starts on the right', () { + final barElements = [ + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 10, 20), (_, __) => 'LabelA') + ]; + + new BarLabelDecorator( + labelAnchor: BarLabelAnchor.start, + labelPosition: BarLabelPosition.inside, + labelPadding: 0, // Turn off label padding for testing. + insideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false, + rtl: true); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(10)); + expect(captured[0].textDirection, equals(TextDirection.rtl)); + expect(captured[1], equals(10)); + expect(captured[2], equals(5)); + }); + + test('RTL TextAnchor.end starts on the left', () { + final barElements = [ + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 10, 20), (_, __) => 'LabelA') + ]; + + new BarLabelDecorator( + labelAnchor: BarLabelAnchor.end, + labelPosition: BarLabelPosition.inside, + labelPadding: 0, // Turn off label padding for testing. + insideLabelStyleSpec: new TextStyleSpec(fontSize: 10)) + .decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false, + rtl: true); + + final captured = + verify(canvas.drawText(captureAny, captureAny, captureAny)).captured; + expect(captured, hasLength(3)); + expect(captured[0].maxWidth, equals(10)); + expect(captured[0].textDirection, equals(TextDirection.ltr)); + expect(captured[1], equals(0)); + expect(captured[2], equals(5)); + }); + }); + + group('Null and empty label scenarios', () { + test('Skip label if label accessor does not exist', () { + final barElements = [ + new FakeBarRendererElement('A', new Rectangle(0, 0, 10, 20), null) + ]; + + new BarLabelDecorator().decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + verifyNever(canvas.drawText(any, any, any)); + }); + + test('Skip label if label is null or empty', () { + final barElements = [ + new FakeBarRendererElement('A', new Rectangle(0, 0, 10, 20), null), + new FakeBarRendererElement( + 'B', new Rectangle(0, 50, 10, 20), (_, __) => ''), + ]; + + new BarLabelDecorator().decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + verifyNever(canvas.drawText(any, any, any)); + }); + + test('Skip label if no width available', () { + final barElements = [ + new FakeBarRendererElement( + 'A', new Rectangle(0, 0, 200, 20), (_, __) => 'a') + ]; + + new BarLabelDecorator( + labelPadding: 0, + labelPosition: BarLabelPosition.outside, + ).decorate(barElements, canvas, graphicsFactory, + drawBounds: drawBounds, + animationPercent: 1.0, + renderingVertically: false); + + verifyNever(canvas.drawText(any, any, any)); + }); + }); +} diff --git a/charts_common/test/chart/bar/bar_renderer_test.dart b/charts_common/test/chart/bar/bar_renderer_test.dart new file mode 100644 index 000000000..3e2ef1b02 --- /dev/null +++ b/charts_common/test/chart/bar/bar_renderer_test.dart @@ -0,0 +1,463 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/bar/bar_renderer.dart'; +import 'package:charts_common/src/chart/bar/bar_renderer_config.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer_config.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart' + show MutableSeries; +import 'package:charts_common/src/common/material_palette.dart' + show MaterialPalette; +import 'package:charts_common/src/data/series.dart' show Series; + +import 'package:test/test.dart'; + +/// Datum/Row for the chart. +class MyRow { + final String campaign; + final int clickCount; + MyRow(this.campaign, this.clickCount); +} + +void main() { + BarRenderer renderer; + List> seriesList; + List> groupedStackedSeriesList; + + setUp(() { + var myFakeDesktopAData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeTabletAData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeMobileAData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeDesktopBData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeTabletBData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeMobileBData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + seriesList = [ + new MutableSeries(new Series( + id: 'Desktop', + colorFn: (_, __) => MaterialPalette.blue.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopAData)), + new MutableSeries(new Series( + id: 'Tablet', + colorFn: (_, __) => MaterialPalette.red.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeTabletAData)), + new MutableSeries(new Series( + id: 'Mobile', + colorFn: (_, __) => MaterialPalette.green.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeMobileAData)) + ]; + + groupedStackedSeriesList = [ + new MutableSeries(new Series( + id: 'Desktop A', + seriesCategory: 'A', + colorFn: (_, __) => MaterialPalette.blue.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopAData)), + new MutableSeries(new Series( + id: 'Tablet A', + seriesCategory: 'A', + colorFn: (_, __) => MaterialPalette.red.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeTabletAData)), + new MutableSeries(new Series( + id: 'Mobile A', + seriesCategory: 'A', + colorFn: (_, __) => MaterialPalette.green.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeMobileAData)), + new MutableSeries(new Series( + id: 'Desktop B', + seriesCategory: 'B', + colorFn: (_, __) => MaterialPalette.blue.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopBData)), + new MutableSeries(new Series( + id: 'Tablet B', + seriesCategory: 'B', + colorFn: (_, __) => MaterialPalette.red.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeTabletBData)), + new MutableSeries(new Series( + id: 'Mobile B', + seriesCategory: 'B', + colorFn: (_, __) => MaterialPalette.green.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeMobileBData)) + ]; + }); + + group('preprocess', () { + test('with grouped bars', () { + renderer = new BarRenderer( + config: new BarRendererConfig(groupingType: BarGroupingType.grouped)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + var elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + + // Validate Tablet series. + series = seriesList[1]; + expect(series.getAttr(barGroupIndexKey), equals(1)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + + // Validate Mobile series. + series = seriesList[2]; + expect(series.getAttr(barGroupIndexKey), equals(2)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + }); + + test('with grouped stacked bars', () { + renderer = new BarRenderer( + config: new BarRendererConfig( + groupingType: BarGroupingType.groupedStacked)); + + renderer.preprocessSeries(groupedStackedSeriesList); + + expect(groupedStackedSeriesList.length, equals(6)); + + // Validate Desktop A series. + var series = groupedStackedSeriesList[0]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('A')); + + var elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(10)); + expect(element.measureOffsetPlusMeasure, equals(15)); + expect(series.measureOffsetFn(series.data[0], 0), equals(10)); + + // Validate Tablet A series. + series = groupedStackedSeriesList[1]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('A')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + + // Validate Mobile A series. + series = groupedStackedSeriesList[2]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('A')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + + // Validate Desktop B series. + series = groupedStackedSeriesList[3]; + expect(series.getAttr(barGroupIndexKey), equals(1)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('B')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(10)); + expect(element.measureOffsetPlusMeasure, equals(15)); + expect(series.measureOffsetFn(series.data[0], 0), equals(10)); + + // Validate Tablet B series. + series = groupedStackedSeriesList[4]; + expect(series.getAttr(barGroupIndexKey), equals(1)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('B')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + + // Validate Mobile B series. + series = groupedStackedSeriesList[5]; + expect(series.getAttr(barGroupIndexKey), equals(1)); + expect(series.getAttr(barGroupCountKey), equals(2)); + expect(series.getAttr(stackKeyKey), equals('B')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + }); + + test('with stacked bars', () { + renderer = new BarRenderer( + config: new BarRendererConfig(groupingType: BarGroupingType.stacked)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + var elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(10)); + expect(element.measureOffsetPlusMeasure, equals(15)); + expect(series.measureOffsetFn(series.data[0], 0), equals(10)); + + // Validate Tablet series. + series = seriesList[1]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + + // Validate Mobile series. + series = seriesList[2]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + }); + + test('with stacked bars containing zero and null', () { + // Set up some nulls and zeros in the data. + seriesList[2].data[0] = new MyRow('MyCampaign1', null); + seriesList[2].data[2] = new MyRow('MyCampaign3', 0); + + seriesList[1].data[1] = new MyRow('MyCampaign2', null); + seriesList[1].data[3] = new MyRow('MyOtherCampaign', 0); + + seriesList[0].data[2] = new MyRow('MyCampaign3', 0); + + renderer = new BarRenderer( + config: new BarRendererConfig(groupingType: BarGroupingType.stacked)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + var elementsList = series.getAttr(barElementsKey); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + + element = elementsList[1]; + expect(element.measureOffset, equals(25)); + expect(element.measureOffsetPlusMeasure, equals(50)); + expect(series.measureOffsetFn(series.data[1], 1), equals(25)); + + element = elementsList[2]; + expect(element.measureOffset, equals(100)); + expect(element.measureOffsetPlusMeasure, equals(100)); + expect(series.measureOffsetFn(series.data[2], 2), equals(100)); + + element = elementsList[3]; + expect(element.measureOffset, equals(75)); + expect(element.measureOffsetPlusMeasure, equals(150)); + expect(series.measureOffsetFn(series.data[3], 3), equals(75)); + + // Validate Tablet series. + series = seriesList[1]; + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + + element = elementsList[1]; + expect(element.measureOffset, equals(25)); + expect(element.measureOffsetPlusMeasure, equals(25)); + expect(series.measureOffsetFn(series.data[1], 1), equals(25)); + + element = elementsList[2]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(100)); + expect(series.measureOffsetFn(series.data[2], 2), equals(0)); + + element = elementsList[3]; + expect(element.measureOffset, equals(75)); + expect(element.measureOffsetPlusMeasure, equals(75)); + expect(series.measureOffsetFn(series.data[3], 3), equals(75)); + + // Validate Mobile series. + series = seriesList[2]; + elementsList = series.getAttr(barElementsKey); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(0)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + + element = elementsList[1]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(25)); + expect(series.measureOffsetFn(series.data[1], 1), equals(0)); + + element = elementsList[2]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(0)); + expect(series.measureOffsetFn(series.data[2], 2), equals(0)); + + element = elementsList[3]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(75)); + expect(series.measureOffsetFn(series.data[3], 3), equals(0)); + }); + }); +} diff --git a/charts_common/test/chart/bar/bar_target_line_renderer_test.dart b/charts_common/test/chart/bar/bar_target_line_renderer_test.dart new file mode 100644 index 000000000..39a1712af --- /dev/null +++ b/charts_common/test/chart/bar/bar_target_line_renderer_test.dart @@ -0,0 +1,366 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/bar/bar_target_line_renderer.dart'; +import 'package:charts_common/src/chart/bar/bar_target_line_renderer_config.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer_config.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart' + show MutableSeries; +import 'package:charts_common/src/data/series.dart' show Series; + +import 'package:test/test.dart'; + +/// Datum/Row for the chart. +class MyRow { + final String campaign; + final int clickCount; + MyRow(this.campaign, this.clickCount); +} + +void main() { + BarTargetLineRenderer renderer; + List> seriesList; + + setUp(() { + var myFakeDesktopData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeTabletData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + var myFakeMobileData = [ + new MyRow('MyCampaign1', 5), + new MyRow('MyCampaign2', 25), + new MyRow('MyCampaign3', 100), + new MyRow('MyOtherCampaign', 75), + ]; + + seriesList = [ + new MutableSeries(new Series( + id: 'Desktop', + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopData)), + new MutableSeries(new Series( + id: 'Tablet', + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeTabletData)), + new MutableSeries(new Series( + id: 'Mobile', + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeMobileData)) + ]; + }); + + group('preprocess', () { + test('with grouped bar target lines', () { + renderer = new BarTargetLineRenderer( + config: new BarTargetLineRendererConfig( + groupingType: BarGroupingType.grouped)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + var elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Tablet series. + series = seriesList[1]; + expect(series.getAttr(barGroupIndexKey), equals(1)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Mobile series. + series = seriesList[2]; + expect(series.getAttr(barGroupIndexKey), equals(2)); + expect(series.getAttr(barGroupCountKey), equals(3)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(null)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + }); + + test('with stacked bar target lines', () { + renderer = new BarTargetLineRenderer( + config: new BarTargetLineRendererConfig( + groupingType: BarGroupingType.stacked)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + var elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(10)); + expect(element.measureOffsetPlusMeasure, equals(15)); + expect(series.measureOffsetFn(series.data[0], 0), equals(10)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Tablet series. + series = seriesList[1]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Mobile series. + series = seriesList[2]; + expect(series.getAttr(barGroupIndexKey), equals(0)); + expect(series.getAttr(barGroupCountKey), equals(1)); + expect(series.getAttr(stackKeyKey), equals('__defaultKey__')); + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + }); + + test('with stacked bar target lines containing zero and null', () { + // Set up some nulls and zeros in the data. + seriesList[2].data[0] = new MyRow('MyCampaign1', null); + seriesList[2].data[2] = new MyRow('MyCampaign3', 0); + + seriesList[1].data[1] = new MyRow('MyCampaign2', null); + seriesList[1].data[3] = new MyRow('MyOtherCampaign', 0); + + seriesList[0].data[2] = new MyRow('MyCampaign3', 0); + + renderer = new BarTargetLineRenderer( + config: new BarTargetLineRendererConfig( + groupingType: BarGroupingType.stacked)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + var elementsList = series.getAttr(barElementsKey); + + var element = elementsList[0]; + expect(element.barStackIndex, equals(2)); + expect(element.measureOffset, equals(5)); + expect(element.measureOffsetPlusMeasure, equals(10)); + expect(series.measureOffsetFn(series.data[0], 0), equals(5)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[1]; + expect(element.measureOffset, equals(25)); + expect(element.measureOffsetPlusMeasure, equals(50)); + expect(series.measureOffsetFn(series.data[1], 1), equals(25)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[2]; + expect(element.measureOffset, equals(100)); + expect(element.measureOffsetPlusMeasure, equals(100)); + expect(series.measureOffsetFn(series.data[2], 2), equals(100)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[3]; + expect(element.measureOffset, equals(75)); + expect(element.measureOffsetPlusMeasure, equals(150)); + expect(series.measureOffsetFn(series.data[3], 3), equals(75)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Tablet series. + series = seriesList[1]; + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.barStackIndex, equals(1)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(5)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[1]; + expect(element.measureOffset, equals(25)); + expect(element.measureOffsetPlusMeasure, equals(25)); + expect(series.measureOffsetFn(series.data[1], 1), equals(25)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[2]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(100)); + expect(series.measureOffsetFn(series.data[2], 2), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[3]; + expect(element.measureOffset, equals(75)); + expect(element.measureOffsetPlusMeasure, equals(75)); + expect(series.measureOffsetFn(series.data[3], 3), equals(75)); + expect(element.strokeWidthPx, equals(3)); + + // Validate Mobile series. + series = seriesList[2]; + elementsList = series.getAttr(barElementsKey); + + element = elementsList[0]; + expect(element.barStackIndex, equals(0)); + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(0)); + expect(series.measureOffsetFn(series.data[0], 0), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[1]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(25)); + expect(series.measureOffsetFn(series.data[1], 1), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[2]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(0)); + expect(series.measureOffsetFn(series.data[2], 2), equals(0)); + expect(element.strokeWidthPx, equals(3)); + + element = elementsList[3]; + expect(element.measureOffset, equals(0)); + expect(element.measureOffsetPlusMeasure, equals(75)); + expect(series.measureOffsetFn(series.data[3], 3), equals(0)); + expect(element.strokeWidthPx, equals(3)); + }); + }); + + test('with stroke width target lines', () { + renderer = new BarTargetLineRenderer( + config: new BarTargetLineRendererConfig( + groupingType: BarGroupingType.grouped, strokeWidthPx: 5.0)); + + renderer.preprocessSeries(seriesList); + + expect(seriesList.length, equals(3)); + + // Validate Desktop series. + var series = seriesList[0]; + var elementsList = series.getAttr(barElementsKey); + + var element = elementsList[0]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[1]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[2]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[3]; + expect(element.strokeWidthPx, equals(5)); + + // Validate Tablet series. + series = seriesList[1]; + + elementsList = series.getAttr(barElementsKey); + expect(elementsList.length, equals(4)); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[1]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[2]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[3]; + expect(element.strokeWidthPx, equals(5)); + + // Validate Mobile series. + series = seriesList[2]; + elementsList = series.getAttr(barElementsKey); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[1]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[2]; + expect(element.strokeWidthPx, equals(5)); + + element = elementsList[3]; + expect(element.strokeWidthPx, equals(5)); + }); +} diff --git a/charts_common/test/chart/bar/renderer_nearest_detail_test.dart b/charts_common/test/chart/bar/renderer_nearest_detail_test.dart new file mode 100644 index 000000000..6f4343ba4 --- /dev/null +++ b/charts_common/test/chart/bar/renderer_nearest_detail_test.dart @@ -0,0 +1,1056 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:charts_common/src/chart/bar/bar_renderer.dart'; +import 'package:charts_common/src/chart/bar/bar_renderer_config.dart'; +import 'package:charts_common/src/chart/bar/bar_target_line_renderer.dart'; +import 'package:charts_common/src/chart/bar/bar_target_line_renderer_config.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer.dart'; +import 'package:charts_common/src/chart/bar/base_bar_renderer_config.dart'; +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/cartesian_chart.dart'; +import 'package:charts_common/src/chart/common/chart_canvas.dart'; +import 'package:charts_common/src/chart/common/chart_context.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/common/color.dart'; +import 'package:charts_common/src/data/series.dart'; + +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +/// Datum/Row for the chart. +class MyRow { + final String campaign; + final int clickCount; + MyRow(this.campaign, this.clickCount); +} + +// TODO: Test in RTL context as well. + +class MockContext extends Mock implements ChartContext {} + +class MockChart extends Mock implements CartesianChart {} + +class MockAxis extends Mock implements Axis {} + +class MockCanvas extends Mock implements ChartCanvas {} + +void main() { + ///////////////////////////////////////// + // Convenience methods for creating mocks. + ///////////////////////////////////////// + _configureBaseRenderer(BaseBarRenderer renderer, bool vertical) { + final context = new MockContext(); + when(context.rtl).thenReturn(false); + final verticalChart = new MockChart(); + when(verticalChart.vertical).thenReturn(vertical); + when(verticalChart.context).thenReturn(context); + renderer.onAttach(verticalChart); + + final layoutBounds = vertical + ? new Rectangle(70, 20, 230, 100) + : new Rectangle(70, 20, 100, 230); + renderer.layout(layoutBounds, layoutBounds); + return renderer; + } + + BaseBarRenderer _makeBarRenderer({bool vertical, BarGroupingType groupType}) { + final renderer = + new BarRenderer(config: new BarRendererConfig(groupingType: groupType)); + _configureBaseRenderer(renderer, vertical); + return renderer; + } + + BaseBarRenderer _makeBarTargetRenderer( + {bool vertical, BarGroupingType groupType}) { + final renderer = new BarTargetLineRenderer( + config: new BarTargetLineRendererConfig(groupingType: groupType)); + _configureBaseRenderer(renderer, vertical); + return renderer; + } + + MutableSeries _makeSeries( + {String id, String seriesCategory, bool vertical = true}) { + final data = [ + new MyRow('camp0', 10), + new MyRow('camp1', 10), + ]; + + final series = new MutableSeries(new Series( + id: id, + data: data, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + seriesCategory: seriesCategory, + )); + + series.measureOffsetFn = (_, __) => 0.0; + series.colorFn = (_, __) => new Color.fromHex(code: '#000000'); + + // Mock the Domain axis results. + final domainAxis = new MockAxis(); + when(domainAxis.rangeBand).thenReturn(100.0); + final domainOffset = vertical ? 70.0 : 20.0; + when(domainAxis.getLocation('camp0')) + .thenReturn(domainOffset + 10.0 + 50.0); + when(domainAxis.getLocation('camp1')) + .thenReturn(domainOffset + 10.0 + 100.0 + 10.0 + 50.0); + when(domainAxis.getLocation('outsideViewport')).thenReturn(-51); + + if (vertical) { + when(domainAxis.getDomain(100.0)).thenReturn('camp0'); + when(domainAxis.getDomain(93.0)).thenReturn('camp0'); + when(domainAxis.getDomain(130.0)).thenReturn('camp0'); + when(domainAxis.getDomain(65.0)).thenReturn('outsideViewport'); + } else { + when(domainAxis.getDomain(50.0)).thenReturn('camp0'); + when(domainAxis.getDomain(43.0)).thenReturn('camp0'); + when(domainAxis.getDomain(80.0)).thenReturn('camp0'); + } + series.setAttr(domainAxisKey, domainAxis); + + // Mock the Measure axis results. + final measureAxis = new MockAxis(); + if (vertical) { + when(measureAxis.getLocation(0.0)).thenReturn(20.0 + 100.0); + when(measureAxis.getLocation(10.0)).thenReturn(20.0 + 100.0 - 10.0); + when(measureAxis.getLocation(20.0)).thenReturn(20.0 + 100.0 - 20.0); + } else { + when(measureAxis.getLocation(0.0)).thenReturn(70.0); + when(measureAxis.getLocation(10.0)).thenReturn(70.0 + 10.0); + when(measureAxis.getLocation(20.0)).thenReturn(70.0 + 20.0); + } + series.setAttr(measureAxisKey, measureAxis); + + return series; + } + + ///////////////////////////////////////// + // Additional edge test cases + ///////////////////////////////////////// + group('edge cases', () { + test('hit target on missing data in group should highlight group', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..data.clear(), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(31)); // 2 + 49 - 20 + expect(closest.measureDistance, equals(0)); + }); + + test('all series without data is skipped', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..data.clear(), + _makeSeries(id: 'bar')..data.clear(), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + + test('single overlay series is skipped', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..overlaySeries = true, + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(31)); // 2 + 49 - 20 + expect(closest.measureDistance, equals(0)); + }); + + test('all overlay series is skipped', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..overlaySeries = true, + _makeSeries(id: 'bar')..overlaySeries = true, + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + }); + + ///////////////////////////////////////// + // Vertical BarRenderer + ///////////////////////////////////////// + group('Vertical BarRenderer', () { + test('hit test works on bar', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.stacked); + final seriesList = [_makeSeries(id: 'foo')]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 13.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + }); + + test('hit test expands to grouped bars', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(31)); // 2 + 49 - 20 + expect(next.measureDistance, equals(0)); + }); + + test('hit test expands to stacked bars', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 13.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + // For vertical stacked bars, the first series is at the top of the stack. + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('foo')); + expect(next.datum, equals(seriesList[0].data[0])); + expect(next.domainDistance, equals(0)); + expect(next.measureDistance, equals(5.0)); + }); + + test('hit test expands to grouped stacked', () { + // Setup + final renderer = _makeBarRenderer( + vertical: true, groupType: BarGroupingType.groupedStacked); + final seriesList = [ + _makeSeries(id: 'foo0', seriesCategory: 'c0'), + _makeSeries(id: 'bar0', seriesCategory: 'c0'), + _makeSeries(id: 'foo1', seriesCategory: 'c1'), + _makeSeries(id: 'bar1', seriesCategory: 'c1'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(4)); + + // For vertical stacked bars, the first series is at the top of the stack. + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar0')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final other1 = details[1]; + expect(other1.domain, equals('camp0')); + expect(other1.series.id, equals('foo0')); + expect(other1.datum, equals(seriesList[0].data[0])); + expect(other1.domainDistance, equals(0)); + expect(other1.measureDistance, equals(5)); + + var other2 = details[2]; + expect(other2.domain, equals('camp0')); + expect(other2.series.id, equals('bar1')); + expect(other2.datum, equals(seriesList[3].data[0])); + expect(other2.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other2.measureDistance, equals(0)); + + var other3 = details[3]; + expect(other3.domain, equals('camp0')); + expect(other3.series.id, equals('foo1')); + expect(other3.datum, equals(seriesList[2].data[0])); + expect(other3.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other3.measureDistance, equals(5)); + }); + + test('hit test works above bar', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.stacked); + final seriesList = [_makeSeries(id: 'foo')]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 13.0, 20.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(90)); + }); + + test('hit test works between bars in a group', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 50.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(1)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(1)); + expect(next.measureDistance, equals(0)); + }); + + test('no selection for bars outside of viewport', () { + // Setup + final renderer = + _makeBarRenderer(vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..data.add(new MyRow('outsideViewport', 20)) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + // Note: point is in the axis, over a bar outside of the viewport. + final details = renderer.getNearestDatumDetailPerSeries( + new Point(65.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + }); + + ///////////////////////////////////////// + // Horizontal BarRenderer + ///////////////////////////////////////// + group('Horizontal BarRenderer', () { + test('hit test works on bar', () { + // Setup + final renderer = + _makeBarRenderer(vertical: false, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 13.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + }); + + test('hit test expands to grouped bars', () { + // Setup + final renderer = + _makeBarRenderer(vertical: false, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(31)); // 2 + 49 - 20 + expect(next.measureDistance, equals(0)); + }); + + test('hit test expands to stacked bars', () { + // Setup + final renderer = + _makeBarRenderer(vertical: false, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(0)); + expect(next.measureDistance, equals(5.0)); + }); + + test('hit test expands to grouped stacked', () { + // Setup + final renderer = _makeBarRenderer( + vertical: false, groupType: BarGroupingType.groupedStacked); + final seriesList = [ + _makeSeries(id: 'foo0', seriesCategory: 'c0', vertical: false), + _makeSeries(id: 'bar0', seriesCategory: 'c0', vertical: false), + _makeSeries(id: 'foo1', seriesCategory: 'c1', vertical: false), + _makeSeries(id: 'bar1', seriesCategory: 'c1', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(4)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo0')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(0)); + + final other1 = details[1]; + expect(other1.domain, equals('camp0')); + expect(other1.series.id, equals('bar0')); + expect(other1.datum, equals(seriesList[1].data[0])); + expect(other1.domainDistance, equals(0)); + expect(other1.measureDistance, equals(5)); + + var other2 = details[2]; + expect(other2.domain, equals('camp0')); + expect(other2.series.id, equals('foo1')); + expect(other2.datum, equals(seriesList[2].data[0])); + expect(other2.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other2.measureDistance, equals(0)); + + var other3 = details[3]; + expect(other3.domain, equals('camp0')); + expect(other3.series.id, equals('bar1')); + expect(other3.datum, equals(seriesList[3].data[0])); + expect(other3.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other3.measureDistance, equals(5)); + }); + + test('hit test works above bar', () { + // Setup + final renderer = + _makeBarRenderer(vertical: false, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 100.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(90)); + }); + + test('hit test works between bars in a group', () { + // Setup + final renderer = + _makeBarRenderer(vertical: false, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 50.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(1)); + expect(closest.measureDistance, equals(0)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(1)); + expect(next.measureDistance, equals(0)); + }); + }); + + ///////////////////////////////////////// + // Vertical BarTargetRenderer + ///////////////////////////////////////// + group('Vertical BarTargetRenderer', () { + test('hit test works above target', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.stacked); + final seriesList = [_makeSeries(id: 'foo')]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 13.0, 20.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(90)); + }); + + test('hit test expands to grouped bar targets', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(31)); // 2 + 49 - 20 + expect(next.measureDistance, equals(5)); + }); + + test('hit test expands to stacked bar targets', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 13.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + // For vertical stacked bars, the first series is at the top of the stack. + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('foo')); + expect(next.datum, equals(seriesList[0].data[0])); + expect(next.domainDistance, equals(0)); + expect(next.measureDistance, equals(15.0)); + }); + + test('hit test expands to grouped stacked', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.groupedStacked); + final seriesList = [ + _makeSeries(id: 'foo0', seriesCategory: 'c0'), + _makeSeries(id: 'bar0', seriesCategory: 'c0'), + _makeSeries(id: 'foo1', seriesCategory: 'c1'), + _makeSeries(id: 'bar1', seriesCategory: 'c1'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 20.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(4)); + + // For vertical stacked bars, the first series is at the top of the stack. + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('bar0')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final other1 = details[1]; + expect(other1.domain, equals('camp0')); + expect(other1.series.id, equals('foo0')); + expect(other1.datum, equals(seriesList[0].data[0])); + expect(other1.domainDistance, equals(0)); + expect(other1.measureDistance, equals(15)); + + var other2 = details[2]; + expect(other2.domain, equals('camp0')); + expect(other2.series.id, equals('bar1')); + expect(other2.datum, equals(seriesList[3].data[0])); + expect(other2.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other2.measureDistance, equals(5)); + + var other3 = details[3]; + expect(other3.domain, equals('camp0')); + expect(other3.series.id, equals('foo1')); + expect(other3.datum, equals(seriesList[2].data[0])); + expect(other3.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other3.measureDistance, equals(15)); + }); + + test('hit test works between targets in a group', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0 + 50.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(1)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(1)); + expect(next.measureDistance, equals(5)); + }); + + test('no selection for targets outside of viewport', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: true, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo')..data.add(new MyRow('outsideViewport', 20)) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + // Note: point is in the axis, over a bar outside of the viewport. + final details = renderer.getNearestDatumDetailPerSeries( + new Point(65.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + }); + + ///////////////////////////////////////// + // Horizontal BarTargetRenderer + ///////////////////////////////////////// + group('Horizontal BarTargetRenderer', () { + test('hit test works above target', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: false, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 100.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(90)); + }); + + test('hit test expands to grouped bar targets', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: false, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(31)); // 2 + 49 - 20 + expect(next.measureDistance, equals(5)); + }); + + test('hit test expands to stacked bar targets', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: false, groupType: BarGroupingType.stacked); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(0)); + expect(next.measureDistance, equals(15)); + }); + + test('hit test expands to grouped stacked', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: false, groupType: BarGroupingType.groupedStacked); + final seriesList = [ + _makeSeries(id: 'foo0', seriesCategory: 'c0', vertical: false), + _makeSeries(id: 'bar0', seriesCategory: 'c0', vertical: false), + _makeSeries(id: 'foo1', seriesCategory: 'c1', vertical: false), + _makeSeries(id: 'bar1', seriesCategory: 'c1', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 20.0)); + + // Verify + expect(details.length, equals(4)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo0')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(0)); + expect(closest.measureDistance, equals(5)); + + final other1 = details[1]; + expect(other1.domain, equals('camp0')); + expect(other1.series.id, equals('bar0')); + expect(other1.datum, equals(seriesList[1].data[0])); + expect(other1.domainDistance, equals(0)); + expect(other1.measureDistance, equals(15)); + + var other2 = details[2]; + expect(other2.domain, equals('camp0')); + expect(other2.series.id, equals('foo1')); + expect(other2.datum, equals(seriesList[2].data[0])); + expect(other2.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other2.measureDistance, equals(5)); + + var other3 = details[3]; + expect(other3.domain, equals('camp0')); + expect(other3.series.id, equals('bar1')); + expect(other3.datum, equals(seriesList[3].data[0])); + expect(other3.domainDistance, equals(31)); // 2 + 49 - 20 + expect(other3.measureDistance, equals(15)); + }); + + test('hit test works between bars in a group', () { + // Setup + final renderer = _makeBarTargetRenderer( + vertical: false, groupType: BarGroupingType.grouped); + final seriesList = [ + _makeSeries(id: 'foo', vertical: false), + _makeSeries(id: 'bar', vertical: false), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 5.0, 20.0 + 10.0 + 50.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals('camp0')); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(1)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals('camp0')); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(1)); + expect(next.measureDistance, equals(5)); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/draw_strategy/tick_draw_strategy_test.dart b/charts_common/test/chart/cartesian/axis/draw_strategy/tick_draw_strategy_test.dart new file mode 100644 index 000000000..65d852647 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/draw_strategy/tick_draw_strategy_test.dart @@ -0,0 +1,404 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; +import 'package:charts_common/src/chart/cartesian/axis/draw_strategy/base_tick_draw_strategy.dart'; +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/axis/spec/axis_spec.dart'; +import 'package:charts_common/src/chart/cartesian/axis/tick.dart'; +import 'package:charts_common/src/chart/common/chart_canvas.dart'; +import 'package:charts_common/src/chart/common/chart_context.dart'; +import 'package:charts_common/src/common/graphics_factory.dart'; +import 'package:charts_common/src/common/line_style.dart'; +import 'package:charts_common/src/common/text_element.dart'; +import 'package:charts_common/src/common/text_measurement.dart'; +import 'package:charts_common/src/common/text_style.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockContext extends Mock implements ChartContext {} + +/// Implementation of [BaseTickDrawStrategy] that does nothing on draw. +class BaseTickDrawStrategyImpl extends BaseTickDrawStrategy { + BaseTickDrawStrategyImpl( + ChartContext chartContext, GraphicsFactory graphicsFactory, + {TextStyleSpec labelStyleSpec, + LineStyleSpec axisLineStyleSpec, + TickLabelAnchor labelAnchor, + TickLabelJustification labelJustification, + int labelOffsetFromAxisPx, + int labelOffsetFromTickPx, + int minimumPaddingBetweenLabelsPx}) + : super(chartContext, graphicsFactory, + labelStyleSpec: labelStyleSpec, + axisLineStyleSpec: axisLineStyleSpec, + labelAnchor: labelAnchor, + labelJustification: labelJustification, + labelOffsetFromAxisPx: labelOffsetFromAxisPx, + labelOffsetFromTickPx: labelOffsetFromTickPx, + minimumPaddingBetweenLabelsPx: minimumPaddingBetweenLabelsPx); + + void draw(ChartCanvas canvas, Tick tick, + {AxisOrientation orientation, + Rectangle axisBounds, + Rectangle drawAreaBounds}) {} +} + +/// Fake [TextElement] which returns the +/// +/// [baseline] returns the same value as the [verticalSliceWidth] specified. +class FakeTextElement implements TextElement { + final String text; + final TextMeasurement measurement; + TextStyle textStyle; + int maxWidth; + MaxWidthStrategy maxWidthStrategy; + TextDirection textDirection; + + FakeTextElement( + this.text, + this.textDirection, + double horizontalSliceWidth, + double verticalSliceWidth, + ) : measurement = new TextMeasurement( + horizontalSliceWidth: horizontalSliceWidth, + verticalSliceWidth: verticalSliceWidth); +} + +class MockGraphicsFactory extends Mock implements GraphicsFactory {} + +class MockLineStyle extends Mock implements LineStyle {} + +class MockTextStyle extends Mock implements TextStyle {} + +/// Helper function to create [Tick] for testing. +Tick createTick(String value, double locationPx, + {double horizontalWidth, + double verticalWidth, + TextDirection textDirection}) { + return new Tick( + value: value, + locationPx: locationPx, + textElement: new FakeTextElement( + value, textDirection, horizontalWidth, verticalWidth)); +} + +void main() { + GraphicsFactory graphicsFactory; + ChartContext chartContext; + + setUpAll(() { + graphicsFactory = new MockGraphicsFactory(); + when(graphicsFactory.createLinePaint()).thenReturn(new MockLineStyle()); + when(graphicsFactory.createTextPaint()).thenReturn(new MockTextStyle()); + + chartContext = new MockContext(); + when(chartContext.rtl).thenReturn(false); + }); + + group('collision detection - vertically drawn axis', () { + test('ticks do not collide', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 20.0, verticalWidth: 8.0), // 20.0 - 30.0 (28.0 + 2) + createTick('C', 30.0, verticalWidth: 8.0), // 30.0 - 40.0 (38.0 + 2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isFalse); + }); + + test('ticks collide because it does not have minimum padding', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 20.0, verticalWidth: 9.0), // 20.0 - 31.0 (28.0 + 3) + createTick('C', 30.0, verticalWidth: 8.0), // 30.0 - 40.0 (38.0 + 2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + + test('first tick causes collision', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 11.0), // 10.0 - 21.0 + createTick('B', 20.0, verticalWidth: 10.0), // 20.0 - 30.0 + createTick('C', 30.0, verticalWidth: 10.0), // 30.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + + test('last tick causes collision', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 10.0), // 10.0 - 20.0 + createTick('B', 20.0, verticalWidth: 10.0), // 20.0 - 30.0 + createTick('C', 29.0, verticalWidth: 10.0), // 29.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + + test('ticks do not collide for inside tick label anchor', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 25.0, verticalWidth: 8.0), // 20.0 - 30.0 (25 + 2 + 1) + createTick('C', 40.0, verticalWidth: 8.0), // 30.0 - 40.0 (40-8-2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isFalse); + }); + + test('ticks collide for inside anchor - first tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 9.0), // 10.0 - 21.0 (19.0 + 2) + createTick('B', 25.0, verticalWidth: 8.0), // 20.0 - 30.0 (25 + 2 + 1) + createTick('C', 40.0, verticalWidth: 8.0), // 30.0 - 40.0 (40-8-2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + + test('ticks collide for inside anchor - center tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 25.0, verticalWidth: 9.0), // 19.5 - 30.5 (25 + 2.5 + 1) + createTick('C', 40.0, verticalWidth: 8.0), // 30.0 - 40.0 (40-8-2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + + test('ticks collide for inside anchor - last tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, verticalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 25.0, verticalWidth: 8.0), // 20.0 - 30.0 (25 + 2 + 1) + createTick('C', 40.0, verticalWidth: 9.0), // 29.0 - 40.0 (40-9-2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.left); + + expect(report.ticksCollide, isTrue); + }); + }); + + group('collision detection - horizontally drawn axis', () { + test('ticks do not collide for TickLabelAnchor.before', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 2, + labelAnchor: TickLabelAnchor.before); + + final ticks = [ + createTick('A', 10.0, horizontalWidth: 8.0), // 10.0 - 20.0 (18.0 + 2) + createTick('B', 20.0, horizontalWidth: 8.0), // 20.0 - 30.0 (28.0 + 2) + createTick('C', 30.0, horizontalWidth: 8.0), // 30.0 - 40.0 (38.0 + 2) + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isFalse); + }); + + test('ticks do not collide for TickLabelAnchor.inside', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, + horizontalWidth: 10.0, + textDirection: TextDirection.ltr), // 10.0 - 20.0 + createTick('B', 25.0, + horizontalWidth: 10.0, + textDirection: TextDirection.center), // 20.0 - 30.0 + createTick('C', 40.0, + horizontalWidth: 10.0, + textDirection: TextDirection.rtl), // 30.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isFalse); + }); + + test('ticks collide - first tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, horizontalWidth: 11.0), // 10.0 - 21.0 + createTick('B', 25.0, horizontalWidth: 10.0), // 20.0 - 30.0 + createTick('C', 40.0, horizontalWidth: 10.0), // 30.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isTrue); + }); + + test('ticks collide - middle tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, horizontalWidth: 10.0), // 10.0 - 20.0 + createTick('B', 25.0, horizontalWidth: 11.0), // 19.5 - 30.5 + createTick('C', 40.0, horizontalWidth: 10.0), // 30.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isTrue); + }); + + test('ticks collide - last tick too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, horizontalWidth: 10.0), // 10.0 - 20.0 + createTick('B', 25.0, horizontalWidth: 10.0), // 20.0 - 30.0 + createTick('C', 40.0, horizontalWidth: 11.0), // 29.0 - 40.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isTrue); + }); + }); + + group('collision detection - unsorted ticks', () { + test('ticks do not collide', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('C', 40.0, horizontalWidth: 10.0), // 30.0 - 40.0 + createTick('B', 25.0, horizontalWidth: 10.0), // 20.0 - 30.0 + createTick('A', 10.0, horizontalWidth: 10.0), // 10.0 - 20.0 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isFalse); + }); + + test('ticks collide - tick B is too large', () { + final drawStrategy = new BaseTickDrawStrategyImpl( + chartContext, graphicsFactory, + minimumPaddingBetweenLabelsPx: 0, + labelAnchor: TickLabelAnchor.inside); + + final ticks = [ + createTick('A', 10.0, horizontalWidth: 10.0), // 10.0 - 20.0 + createTick('C', 40.0, horizontalWidth: 10.0), // 30.0 - 40.0 + createTick('B', 25.0, horizontalWidth: 11.0), // 19.5 - 30.5 + ]; + + final report = drawStrategy.collides(ticks, AxisOrientation.bottom); + + expect(report.ticksCollide, isTrue); + }); + }); + + group('collision detection - corner cases', () { + test('null ticks do not collide', () { + final drawStrategy = + new BaseTickDrawStrategyImpl(chartContext, graphicsFactory); + + final report = drawStrategy.collides(null, AxisOrientation.left); + + expect(report.ticksCollide, isFalse); + }); + + test('empty tick list do not collide', () { + final drawStrategy = + new BaseTickDrawStrategyImpl(chartContext, graphicsFactory); + + final report = drawStrategy.collides([], AxisOrientation.left); + + expect(report.ticksCollide, isFalse); + }); + + test('single tick does not collide', () { + final drawStrategy = + new BaseTickDrawStrategyImpl(chartContext, graphicsFactory); + + final report = drawStrategy.collides( + [createTick('A', 10.0, horizontalWidth: 10.0)], + AxisOrientation.bottom); + + expect(report.ticksCollide, isFalse); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/linear/linear_scale_test.dart b/charts_common/test/chart/cartesian/axis/linear/linear_scale_test.dart new file mode 100644 index 000000000..0ed704155 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/linear/linear_scale_test.dart @@ -0,0 +1,307 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/cartesian/axis/numeric_extents.dart' + show NumericExtents; +import 'package:charts_common/src/chart/cartesian/axis/linear/linear_scale.dart'; +import 'package:charts_common/src/chart/cartesian/axis/scale.dart' + show RangeBandConfig, ScaleOutputExtent, StepSizeConfig; + +import 'package:test/test.dart'; + +const EPSILON = 0.001; + +void main() { + group('Stacking bars', () { + test('basic apply survives copy and reset', () { + LinearScale scale = new LinearScale(); + scale.addDomain(100.0); + scale.addDomain(130.0); + scale.addDomain(200.0); + scale.addDomain(170.0); + scale.range = new ScaleOutputExtent(2000, 1000); + + expect(scale.range.start, equals(2000)); + expect(scale.range.end, equals(1000)); + expect(scale.range.diff, equals(-1000)); + + expect(scale.dataExtent.min, equals(100.0)); + expect(scale.dataExtent.max, equals(200.0)); + + expect(scale[100.0], closeTo(2000, EPSILON)); + expect(scale[200.0], closeTo(1000, EPSILON)); + expect(scale[166.0], closeTo(1340, EPSILON)); + expect(scale[0.0], closeTo(3000, EPSILON)); + expect(scale[300.0], closeTo(0, EPSILON)); + + // test copy + LinearScale other = scale.copy(); + expect(other[166.0], closeTo(1340, EPSILON)); + expect(other.range.start, equals(2000)); + expect(other.range.end, equals(1000)); + + // test reset + other.resetDomain(); + other.resetViewportSettings(); + other.addDomain(10.0); + other.addDomain(20.0); + expect(other.dataExtent.min, equals(10.0)); + expect(other.dataExtent.max, equals(20.0)); + expect(other.viewportDomain.min, equals(10.0)); + expect(other.viewportDomain.max, equals(20.0)); + + expect(other[15.0], closeTo(1500, EPSILON)); + // original scale shouldn't have been touched. + expect(scale[166.0], closeTo(1340, EPSILON)); + + // should always return true. + expect(scale.canTranslate(3.14), isTrue); + }); + + test('viewport assigned domain extent applies to scale', () { + LinearScale scale = new LinearScale()..keepViewportWithinData = false; + scale.addDomain(50.0); + scale.addDomain(70.0); + scale.viewportDomain = new NumericExtents(100.0, 200.0); + scale.range = new ScaleOutputExtent(0, 200); + + expect(scale[200.0], closeTo(200, EPSILON)); + expect(scale[100.0], closeTo(0, EPSILON)); + expect(scale[50.0], closeTo(-100, EPSILON)); + expect(scale[150.0], closeTo(100, EPSILON)); + + scale.resetDomain(); + scale.resetViewportSettings(); + scale.addDomain(50.0); + scale.addDomain(100.0); + scale.viewportDomain = new NumericExtents(0.0, 100.0); + scale.range = new ScaleOutputExtent(0, 200); + + expect(scale[0.0], closeTo(0, EPSILON)); + expect(scale[100.0], closeTo(200, EPSILON)); + expect(scale[50.0], closeTo(100, EPSILON)); + expect(scale[200.0], closeTo(400, EPSILON)); + }); + + test('comparing domain and range to viewport handles extent edges', () { + LinearScale scale = new LinearScale(); + scale.range = new ScaleOutputExtent(1000, 1400); + scale.domainOverride = new NumericExtents(100.0, 300.0); + scale.viewportDomain = new NumericExtents(200.0, 300.0); + + expect(scale.viewportDomain, equals(new NumericExtents(200.0, 300.0))); + + expect(scale[210.0], closeTo(1040, EPSILON)); + expect(scale[400.0], closeTo(1800, EPSILON)); + expect(scale[100.0], closeTo(600, EPSILON)); + + expect(scale.compareDomainValueToViewport(199.0), equals(-1)); + expect(scale.compareDomainValueToViewport(200.0), equals(0)); + expect(scale.compareDomainValueToViewport(201.0), equals(0)); + expect(scale.compareDomainValueToViewport(299.0), equals(0)); + expect(scale.compareDomainValueToViewport(300.0), equals(0)); + expect(scale.compareDomainValueToViewport(301.0), equals(1)); + + expect(scale.isRangeValueWithinViewport(999.0), isFalse); + expect(scale.isRangeValueWithinViewport(1100.0), isTrue); + expect(scale.isRangeValueWithinViewport(1401.0), isFalse); + }); + + test('scale applies in reverse', () { + LinearScale scale = new LinearScale(); + scale.range = new ScaleOutputExtent(1000, 1400); + scale.domainOverride = new NumericExtents(100.0, 300.0); + scale.viewportDomain = new NumericExtents(200.0, 300.0); + + expect(scale.reverse(1040.0), closeTo(210.0, EPSILON)); + expect(scale.reverse(1800.0), closeTo(400.0, EPSILON)); + expect(scale.reverse(600.0), closeTo(100.0, EPSILON)); + }); + + test('scale works with a range from larger to smaller', () { + LinearScale scale = new LinearScale(); + scale.range = new ScaleOutputExtent(1400, 1000); + scale.domainOverride = new NumericExtents(100.0, 300.0); + scale.viewportDomain = new NumericExtents(200.0, 300.0); + + expect(scale[200.0], closeTo(1400.0, EPSILON)); + expect(scale[250.0], closeTo(1200.0, EPSILON)); + expect(scale[300.0], closeTo(1000.0, EPSILON)); + }); + + test('scaleFactor and translate applies to scale', () { + LinearScale scale = new LinearScale(); + scale.range = new ScaleOutputExtent(1000, 1200); + scale.domainOverride = new NumericExtents(100.0, 200.0); + scale.setViewportSettings(4.0, -50.0); + + expect(scale[100.0], closeTo(950.0, EPSILON)); + expect(scale[200.0], closeTo(1750.0, EPSILON)); + expect(scale[150.0], closeTo(1350.0, EPSILON)); + expect(scale[106.25], closeTo(1000.0, EPSILON)); + expect(scale[131.25], closeTo(1200.0, EPSILON)); + + expect(scale.compareDomainValueToViewport(106.0), equals(-1)); + expect(scale.compareDomainValueToViewport(106.25), equals(0)); + expect(scale.compareDomainValueToViewport(107.0), equals(0)); + + expect(scale.compareDomainValueToViewport(131.0), equals(0)); + expect(scale.compareDomainValueToViewport(131.25), equals(0)); + expect(scale.compareDomainValueToViewport(132.0), equals(1)); + + expect(scale.isRangeValueWithinViewport(999.0), isFalse); + expect(scale.isRangeValueWithinViewport(1100.0), isTrue); + expect(scale.isRangeValueWithinViewport(1201.0), isFalse); + }); + + test('scale handles single point', () { + LinearScale domainScale = new LinearScale(); + domainScale.range = new ScaleOutputExtent(1000, 1200); + domainScale.addDomain(50.0); + + // A single point should render in the middle of the scale. + expect(domainScale[50.0], closeTo(1100.0, EPSILON)); + }); + + test('testAllZeros', () { + LinearScale measureScale = new LinearScale(); + measureScale.range = new ScaleOutputExtent(1000, 1200); + measureScale.addDomain(0.0); + + expect(measureScale[0.0], closeTo(1100.0, EPSILON)); + }); + + test('scale calculates step size', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.addDomain(1.0); + scale.addDomain(3.0); + scale.addDomain(11.0); + scale.range = new ScaleOutputExtent(100, 200); + + // 1 - 11 has 6 steps of size 2, 0 - 12 + expect(scale.rangeBand, closeTo(100.0 / 6.0, EPSILON)); + }); + + test('scale applies rangeBand to detected step size', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(0.5); + scale.addDomain(1.0); + scale.addDomain(2.0); + scale.addDomain(10.0); + scale.range = new ScaleOutputExtent(100, 200); + + // 100 range / 10 steps * 0.5percentStep = 5 + expect(scale.rangeBand, closeTo(5.0, EPSILON)); + }); + + test('scale stepSize calculation survives copy', () { + LinearScale scale = new LinearScale(); + scale.stepSizeConfig = new StepSizeConfig.fixedDomain(1.0); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.addDomain(1.0); + scale.addDomain(3.0); + scale.range = new ScaleOutputExtent(100, 200); + expect(scale.copy().rangeBand, closeTo(100.0 / 3.0, EPSILON)); + }); + + test('scale rangeBand calculation survives copy', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.fixedPixel(123.0); + scale.addDomain(1.0); + scale.addDomain(3.0); + scale.range = new ScaleOutputExtent(100, 200); + + expect(scale.copy().rangeBand, closeTo(123, EPSILON)); + }); + + test('scale rangeBand works for single domain value', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.addDomain(1.0); + scale.range = new ScaleOutputExtent(100, 200); + + expect(scale.rangeBand, closeTo(100, EPSILON)); + }); + + test('scale rangeBand works for multiple domains of the same value', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.addDomain(1.0); + scale.addDomain(1.0); + scale.range = new ScaleOutputExtent(100, 200); + + expect(scale.rangeBand, closeTo(100.0, EPSILON)); + }); + + test('scale rangeBand is zero when no domains are added', () { + LinearScale scale = new LinearScale(); + scale.range = new ScaleOutputExtent(100, 200); + + expect(scale.rangeBand, closeTo(0.0, EPSILON)); + }); + + test('scale domain info reset on resetDomain', () { + LinearScale scale = new LinearScale(); + scale.addDomain(1.0); + scale.addDomain(3.0); + scale.range = new ScaleOutputExtent(100, 200); + scale.setViewportSettings(1000.0, 2000.0); + + scale.resetDomain(); + scale.resetViewportSettings(); + expect(scale.viewportScalingFactor, closeTo(1.0, EPSILON)); + expect(scale.viewportTranslatePx, closeTo(0, EPSILON)); + expect(scale.range, equals(new ScaleOutputExtent(100, 200))); + }); + + test('scale handles null domain values', () { + LinearScale scale = new LinearScale(); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.addDomain(1.0); + scale.addDomain(null); + scale.addDomain(3.0); + scale.addDomain(11.0); + scale.range = new ScaleOutputExtent(100, 200); + + expect(scale.rangeBand, closeTo(100.0 / 6.0, EPSILON)); + }); + + test('scale domainOverride survives copy', () { + LinearScale scale = new LinearScale()..keepViewportWithinData = false; + scale.addDomain(1.0); + scale.addDomain(3.0); + scale.range = new ScaleOutputExtent(100, 200); + scale.setViewportSettings(2.0, 10.0); + scale.domainOverride = new NumericExtents(0.0, 100.0); + + LinearScale other = scale.copy(); + + expect(other.domainOverride, equals(new NumericExtents(0.0, 100.0))); + expect(other[5.0], closeTo(120.0, EPSILON)); + }); + + test('scale calculates a scaleFactor given a domain window', () { + LinearScale scale = new LinearScale(); + scale.addDomain(100.0); + scale.addDomain(130.0); + scale.addDomain(200.0); + scale.addDomain(170.0); + + expect(scale.computeViewportScaleFactor(10.0), closeTo(10, EPSILON)); + expect(scale.computeViewportScaleFactor(100.0), closeTo(1, EPSILON)); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/numeric_tick_provider_test.dart b/charts_common/test/chart/cartesian/axis/numeric_tick_provider_test.dart new file mode 100644 index 000000000..ac95022b1 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/numeric_tick_provider_test.dart @@ -0,0 +1,408 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/axis/draw_strategy/base_tick_draw_strategy.dart'; +import 'package:charts_common/src/common/graphics_factory.dart'; +import 'package:charts_common/src/common/line_style.dart'; +import 'package:charts_common/src/common/text_style.dart'; +import 'package:charts_common/src/common/text_element.dart'; +import 'package:charts_common/src/chart/common/chart_canvas.dart'; +import 'package:charts_common/src/chart/common/chart_context.dart'; +import 'package:charts_common/src/chart/common/unitconverter/unit_converter.dart'; +import 'package:charts_common/src/chart/cartesian/axis/collision_report.dart'; +import 'package:charts_common/src/chart/cartesian/axis/numeric_scale.dart'; +import 'package:charts_common/src/chart/cartesian/axis/tick.dart'; +import 'package:charts_common/src/chart/cartesian/axis/tick_formatter.dart'; +import 'package:charts_common/src/chart/cartesian/axis/numeric_extents.dart'; +import 'package:charts_common/src/chart/cartesian/axis/numeric_tick_provider.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockNumericScale extends Mock implements NumericScale {} + +/// A fake draw strategy that reports collision and alternate ticks +/// +/// Reports collision when the tick count is greater than or equal to +/// [collidesAfterTickCount]. +/// +/// Reports alternate rendering after tick count is greater than or equal to +/// [alternateRenderingAfterTickCount]. +class FakeDrawStrategy extends BaseTickDrawStrategy { + final int collidesAfterTickCount; + final int alternateRenderingAfterTickCount; + + FakeDrawStrategy( + this.collidesAfterTickCount, this.alternateRenderingAfterTickCount) + : super(null, new FakeGraphicsFactory()); + + @override + CollisionReport collides(List> ticks, _) { + final ticksCollide = ticks.length >= collidesAfterTickCount; + final alternateTicksUsed = ticks.length >= alternateRenderingAfterTickCount; + + return new CollisionReport( + ticksCollide: ticksCollide, + ticks: ticks, + alternateTicksUsed: alternateTicksUsed); + } + + @override + void draw(ChartCanvas canvas, Tick tick, + {AxisOrientation orientation, + Rectangle axisBounds, + Rectangle drawAreaBounds}) {} +} + +/// A fake [GraphicsFactory] that returns [MockTextStyle] and [MockTextElement]. +class FakeGraphicsFactory extends GraphicsFactory { + @override + TextStyle createTextPaint() => new MockTextStyle(); + + @override + TextElement createTextElement(String text) => new MockTextElement(); + + @override + LineStyle createLinePaint() => new MockLinePaint(); +} + +class MockTextStyle extends Mock implements TextStyle {} + +class MockTextElement extends Mock implements TextElement {} + +class MockLinePaint extends Mock implements LineStyle {} + +class MockChartContext extends Mock implements ChartContext {} + +/// A celsius to fahrenheit converter for testing axis with unit converter. +class CelsiusToFahrenheitConverter implements UnitConverter { + const CelsiusToFahrenheitConverter(); + + @override + num convert(num value) => (value * 1.8) + 32.0; + + @override + num invert(num value) => (value - 32.0) / 1.8; +} + +void main() { + FakeGraphicsFactory graphicsFactory; + MockNumericScale scale; + NumericTickProvider tickProvider; + TickFormatter formatter; + ChartContext context; + + setUp(() { + graphicsFactory = new FakeGraphicsFactory(); + scale = new MockNumericScale(); + tickProvider = new NumericTickProvider(); + formatter = new NumericTickFormatter(); + context = new MockChartContext(); + }); + + test('singleTickCount_choosesTicksWithSmallestStepCoveringDomain', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = false + ..setFixedTickCount(4) + ..allowedSteps = [1.0, 2.5, 5.0]; + final drawStrategy = new FakeDrawStrategy(10, 10); + when(scale.viewportDomain).thenReturn(new NumericExtents(10.0, 70.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks, hasLength(4)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(25)); + expect(ticks[2].value, equals(50)); + expect(ticks[3].value, equals(75)); + }); + + test( + 'tickCountRangeChoosesTicksWithMostTicksAndSmallestIntervalCoveringDomain', + () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = false + ..setTickCount(5, 3) + ..allowedSteps = [1.0, 2.5, 5.0]; + final drawStrategy = new FakeDrawStrategy(10, 10); + when(scale.viewportDomain).thenReturn(new NumericExtents(10.0, 80.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks, hasLength(5)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(25)); + expect(ticks[2].value, equals(50)); + expect(ticks[3].value, equals(75)); + expect(ticks[4].value, equals(100)); + }); + + test('choosesNonAlternateRenderingTicksEvenIfIntervalIsLarger', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = false + ..setTickCount(5, 3) + ..allowedSteps = [1.0, 2.5, 6.0]; + final drawStrategy = new FakeDrawStrategy(10, 5); + when(scale.viewportDomain).thenReturn(new NumericExtents(10.0, 80.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks, hasLength(3)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(60)); + expect(ticks[2].value, equals(120)); + }); + + test('choosesNonCollidingTicksEvenIfIntervalIsLarger', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = false + ..setTickCount(5, 3) + ..allowedSteps = [1.0, 2.5, 6.0]; + final drawStrategy = new FakeDrawStrategy(5, 5); + when(scale.viewportDomain).thenReturn(new NumericExtents(10.0, 80.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks, hasLength(3)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(60)); + expect(ticks[2].value, equals(120)); + }); + + test('zeroBound_alwaysReturnsZeroTick', () { + tickProvider + ..zeroBound = true + ..dataIsInWholeNumbers = false + ..setFixedTickCount(3) + ..allowedSteps = [1.0, 2.5, 5.0]; + final drawStrategy = new FakeDrawStrategy(10, 10); + when(scale.viewportDomain).thenReturn(new NumericExtents(55.0, 135.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + final tickValues = ticks.map((tick) => tick.value).toList(); + + expect(tickValues, contains(0.0)); + }); + + test('boundsCrossOrigin_alwaysReturnsZeroTick', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = false + ..setFixedTickCount(3) + ..allowedSteps = [1.0, 2.5, 5.0]; + final drawStrategy = new FakeDrawStrategy(10, 10); + when(scale.viewportDomain).thenReturn(new NumericExtents(-55.0, 135.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + final tickValues = ticks.map((tick) => tick.value).toList(); + + expect(tickValues, contains(0.0)); + }); + + test('dataIsWholeNumbers_returnsWholeNumberTicks', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = true + ..setFixedTickCount(3) + ..allowedSteps = [1.0, 2.5, 5.0]; + final drawStrategy = new FakeDrawStrategy(10, 10); + + when(scale.viewportDomain).thenReturn(new NumericExtents(0.25, 0.75)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(1)); + expect(ticks[2].value, equals(2)); + }); + + test('choosesTicksBasedOnPreferredAxisUnits', () { + tickProvider + ..zeroBound = true + ..dataIsInWholeNumbers = false + ..setFixedTickCount(3) + ..allowedSteps = [5.0] + ..dataToAxisUnitConverter = const CelsiusToFahrenheitConverter(); + + final drawStrategy = new FakeDrawStrategy(10, 10); + + when(scale.viewportDomain).thenReturn(new NumericExtents(0.0, 20.0)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks[0].value, closeTo(-17.8, 0.1)); // 0 in axis units + expect(ticks[1].value, closeTo(10, 0.1)); // 50 in axis units + expect(ticks[2].value, closeTo(37.8, 0.1)); // 100 in axis units + }); + + test('handlesVerySmallMeasures', () { + tickProvider + ..zeroBound = true + ..dataIsInWholeNumbers = false + ..setFixedTickCount(5); + + final drawStrategy = new FakeDrawStrategy(10, 10); + + when(scale.viewportDomain) + .thenReturn(new NumericExtents(0.000001, 0.000002)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks.length, equals(5)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(0.0000005)); + expect(ticks[2].value, equals(0.0000010)); + expect(ticks[3].value, equals(0.0000015)); + expect(ticks[4].value, equals(0.000002)); + }); + + test('handlesVerySmallMeasuresForWholeNumbers', () { + tickProvider + ..zeroBound = true + ..dataIsInWholeNumbers = true + ..setFixedTickCount(5); + + final drawStrategy = new FakeDrawStrategy(10, 10); + + when(scale.viewportDomain) + .thenReturn(new NumericExtents(0.000001, 0.000002)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks.length, equals(5)); + expect(ticks[0].value, equals(0)); + expect(ticks[1].value, equals(1)); + expect(ticks[2].value, equals(2)); + expect(ticks[3].value, equals(3)); + expect(ticks[4].value, equals(4)); + }); + + test('handlesVerySmallMeasuresForWholeNumbersWithoutZero', () { + tickProvider + ..zeroBound = false + ..dataIsInWholeNumbers = true + ..setFixedTickCount(5); + + final drawStrategy = new FakeDrawStrategy(10, 10); + + when(scale.viewportDomain) + .thenReturn(new NumericExtents(101.000001, 101.000002)); + when(scale.rangeWidth).thenReturn(1000); + + final ticks = tickProvider.getTicks( + context: context, + graphicsFactory: graphicsFactory, + scale: scale, + formatter: formatter, + formatterValueCache: {}, + tickDrawStrategy: drawStrategy, + orientation: null); + + expect(ticks.length, equals(5)); + expect(ticks[0].value, equals(101)); + expect(ticks[1].value, equals(102)); + expect(ticks[2].value, equals(103)); + expect(ticks[3].value, equals(104)); + expect(ticks[4].value, equals(105)); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/ordinal_scale_test.dart b/charts_common/test/chart/cartesian/axis/ordinal_scale_test.dart new file mode 100644 index 000000000..f458ec950 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/ordinal_scale_test.dart @@ -0,0 +1,250 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/cartesian/axis/scale.dart'; +import 'package:charts_common/src/chart/cartesian/axis/simple_ordinal_scale.dart'; + +import 'package:test/test.dart'; + +const EPSILON = 0.001; + +void main() { + SimpleOrdinalScale scale; + + setUp(() { + scale = new SimpleOrdinalScale(); + scale.addDomain('a'); + scale.addDomain('b'); + scale.addDomain('c'); + scale.addDomain('d'); + + scale.range = new ScaleOutputExtent(2000, 1000); + }); + + group('conversion', () { + test('with duplicate keys', () { + scale.addDomain('c'); + scale.addDomain('a'); + + // Current RangeBandConfig.styleAssignedPercent sets size to 0.65 percent. + expect(scale.rangeBand, closeTo(250 * 0.65, EPSILON)); + expect(scale['a'], closeTo(2000 - 125, EPSILON)); + expect(scale['b'], closeTo(2000 - 375, EPSILON)); + expect(scale['c'], closeTo(2000 - 625, EPSILON)); + }); + + test('invalid domain does not throw exception', () { + scale['e']; + }); + + test('invalid domain can translate is false', () { + expect(scale.canTranslate('e'), isFalse); + }); + }); + + group('copy', () { + test('can convert domain', () { + final copied = scale.copy(); + expect(copied['c'], closeTo(2000 - 625, EPSILON)); + }); + + test('does not affect original', () { + final copied = scale.copy(); + copied.addDomain('bar'); + + expect(copied.canTranslate('bar'), isTrue); + expect(scale.canTranslate('bar'), isFalse); + }); + }); + + group('reset', () { + test('clears domains', () { + scale.resetDomain(); + scale.addDomain('foo'); + scale.addDomain('bar'); + + expect(scale['foo'], closeTo(2000 - 250, EPSILON)); + }); + }); + + group('set RangeBandConfig', () { + test('fixed pixel range band changes range band', () { + scale.rangeBandConfig = new RangeBandConfig.fixedPixel(123.0); + + expect(scale.rangeBand, closeTo(123.0, EPSILON)); + + // Adding another domain to ensure it still doesn't change. + scale.addDomain('foo'); + expect(scale.rangeBand, closeTo(123.0, EPSILON)); + }); + + test('percent range band changes range band', () { + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(0.5); + // 125 = 0.5f * 1000pixels / 4domains + expect(scale.rangeBand, closeTo(125.0, EPSILON)); + }); + + test('space from step changes range band', () { + scale.rangeBandConfig = + new RangeBandConfig.fixedPixelSpaceBetweenStep(50.0); + // 200 = 1000pixels / 4domains) - 50 + expect(scale.rangeBand, closeTo(200.0, EPSILON)); + }); + + test('fixed domain throws argument exception', () { + expect(() => scale.rangeBandConfig = new RangeBandConfig.fixedDomain(5.0), + throwsArgumentError); + }); + + test('type of none throws argument exception', () { + expect(() => scale.rangeBandConfig = new RangeBandConfig.none(), + throwsArgumentError); + }); + + test('set to null throws argument exception', () { + expect(() => scale.rangeBandConfig = null, throwsArgumentError); + }); + }); + + group('set step size config', () { + test('to null does not throw', () { + scale.stepSizeConfig = null; + }); + + test('to auto does not throw', () { + scale.stepSizeConfig = new StepSizeConfig.auto(); + }); + + test('to fixed domain throw arugment exception', () { + expect(() => scale.stepSizeConfig = new StepSizeConfig.fixedDomain(1.0), + throwsArgumentError); + }); + + test('to fixed pixel throw arugment exception', () { + expect(() => scale.stepSizeConfig = new StepSizeConfig.fixedPixels(1.0), + throwsArgumentError); + }); + }); + + group('set range persists', () { + test('', () { + expect(scale.range.start, equals(2000)); + expect(scale.range.end, equals(1000)); + expect(scale.range.min, equals(1000)); + expect(scale.range.max, equals(2000)); + expect(scale.rangeWidth, equals(1000)); + + expect(scale.isRangeValueWithinViewport(1500.0), isTrue); + expect(scale.isRangeValueWithinViewport(1000.0), isTrue); + expect(scale.isRangeValueWithinViewport(2000.0), isTrue); + + expect(scale.isRangeValueWithinViewport(500.0), isFalse); + expect(scale.isRangeValueWithinViewport(2500.0), isFalse); + }); + }); + + group('scale factor', () { + test('sets', () { + scale.setViewportSettings(2.0, -700.0); + + expect(scale.viewportScalingFactor, closeTo(2.0, EPSILON)); + expect(scale.viewportTranslatePx, closeTo(-700.0, EPSILON)); + }); + + test('rangeband is scaled', () { + scale.setViewportSettings(2.0, -700.0); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + + expect(scale.rangeBand, closeTo(500.0, EPSILON)); + }); + + test('translate to pixels is scaled', () { + scale.setViewportSettings(2.0, -700.0); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.range = new ScaleOutputExtent(1000, 2000); + + final scaledStepWidth = 500.0; + final scaledInitialShift = 250.0; + + expect(scale['a'], closeTo(1000 + scaledInitialShift - 700, EPSILON)); + + expect(scale['b'], + closeTo(1000 + scaledInitialShift - 700 + scaledStepWidth, EPSILON)); + }); + + test('only b and c should be within the viewport', () { + scale.setViewportSettings(2.0, -700.0); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(1.0); + scale.range = new ScaleOutputExtent(1000, 2000); + + expect(scale.compareDomainValueToViewport('a'), equals(-1)); + expect(scale.compareDomainValueToViewport('c'), equals(0)); + expect(scale.compareDomainValueToViewport('d'), equals(1)); + expect(scale.compareDomainValueToViewport('f'), isNot(0)); + }); + }); + + group('viewport', () { + test('set adjust scale to show viewport', () { + scale.range = new ScaleOutputExtent(1000, 2000); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(0.5); + scale.setViewport(2, 'b'); + + expect(scale['a'], closeTo(750, EPSILON)); + expect(scale['b'], closeTo(1250, EPSILON)); + expect(scale['c'], closeTo(1750, EPSILON)); + expect(scale['d'], closeTo(2250, EPSILON)); + expect(scale.compareDomainValueToViewport('a'), equals(-1)); + expect(scale.compareDomainValueToViewport('b'), equals(0)); + expect(scale.compareDomainValueToViewport('c'), equals(0)); + expect(scale.compareDomainValueToViewport('d'), equals(1)); + }); + + test('illegal to set window size less than one', () { + expect(() => scale.setViewport(0, 'b'), throwsArgumentError); + }); + + test('set starting value if starting domain is not in domain list', () { + scale.range = new ScaleOutputExtent(1000, 2000); + scale.rangeBandConfig = new RangeBandConfig.percentOfStep(0.5); + scale.setViewport(2, 'f'); + + expect(scale['a'], closeTo(1250, EPSILON)); + expect(scale['b'], closeTo(1750, EPSILON)); + expect(scale['c'], closeTo(2250, EPSILON)); + expect(scale['d'], closeTo(2750, EPSILON)); + }); + + test('get size returns number of full steps that fit scale range', () { + scale.range = new ScaleOutputExtent(1000, 2000); + + scale.setViewportSettings(2.0, 0.0); + expect(scale.viewportDataSize, equals(2)); + + scale.setViewportSettings(5.0, 0.0); + expect(scale.viewportDataSize, equals(0)); + }); + + test('get starting viewport gets first fully visible domain', () { + scale.range = new ScaleOutputExtent(1000, 2000); + + scale.setViewportSettings(2.0, -500.0); + expect(scale.viewportStartingDomain, equals('b')); + + scale.setViewportSettings(2.0, -100.0); + expect(scale.viewportStartingDomain, equals('b')); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/time/date_time_tick_formatter_test.dart b/charts_common/test/chart/cartesian/axis/time/date_time_tick_formatter_test.dart new file mode 100644 index 000000000..8b5d90964 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/time/date_time_tick_formatter_test.dart @@ -0,0 +1,254 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/cartesian/axis/time/time_tick_formatter.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/date_time_tick_formatter.dart'; +import 'package:test/test.dart'; + +const EPSILON = 0.001; + +typedef bool IsTransitionFunction(DateTime tickValue, DateTime prevTickValue); + +class FakeTimeTickFormatter implements TimeTickFormatter { + static const firstTick = '-firstTick-'; + static const simpleTick = '-simpleTick-'; + static const transitionTick = '-transitionTick-'; + static final transitionAlwaysFalse = (_, __) => false; + + final String id; + final IsTransitionFunction isTransitionFunction; + + FakeTimeTickFormatter(this.id, {IsTransitionFunction isTransitionFunction}) + : isTransitionFunction = isTransitionFunction ?? transitionAlwaysFalse; + + @override + String formatFirstTick(DateTime date) => + id + firstTick + date.millisecondsSinceEpoch.toString(); + + @override + String formatSimpleTick(DateTime date) => + id + simpleTick + date.millisecondsSinceEpoch.toString(); + + @override + String formatTransitionTick(DateTime date) => + id + transitionTick + date.millisecondsSinceEpoch.toString(); + + @override + bool isTransition(DateTime tickValue, DateTime prevTickValue) => + isTransitionFunction(tickValue, prevTickValue); +} + +void main() { + TimeTickFormatter timeFormatter1; + TimeTickFormatter timeFormatter2; + TimeTickFormatter timeFormatter3; + + setUp(() { + timeFormatter1 = new FakeTimeTickFormatter('fake1'); + timeFormatter2 = new FakeTimeTickFormatter('fake2'); + timeFormatter3 = new FakeTimeTickFormatter('fake3'); + }); + + group('Uses formatter', () { + test('with largest interval less than diff between tickValues', () { + final formatter = new DateTimeTickFormatter.withFormatters( + {10: timeFormatter1, 100: timeFormatter2, 1000: timeFormatter3}); + final formatterCache = {}; + + final ticksWith10Diff = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(10), + new DateTime.fromMillisecondsSinceEpoch(20) + ]; + final ticksWith20Diff = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(20), + new DateTime.fromMillisecondsSinceEpoch(40) + ]; + final ticksWith100Diff = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(100), + new DateTime.fromMillisecondsSinceEpoch(200) + ]; + final ticksWith200Diff = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(200), + new DateTime.fromMillisecondsSinceEpoch(400) + ]; + final ticksWith1000Diff = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(1000), + new DateTime.fromMillisecondsSinceEpoch(2000) + ]; + + final expectedLabels10Diff = [ + 'fake1-firstTick-0', + 'fake1-simpleTick-10', + 'fake1-simpleTick-20' + ]; + final expectedLabels20Diff = [ + 'fake1-firstTick-0', + 'fake1-simpleTick-20', + 'fake1-simpleTick-40' + ]; + final expectedLabels100Diff = [ + 'fake2-firstTick-0', + 'fake2-simpleTick-100', + 'fake2-simpleTick-200' + ]; + final expectedLabels200Diff = [ + 'fake2-firstTick-0', + 'fake2-simpleTick-200', + 'fake2-simpleTick-400' + ]; + final expectedLabels1000Diff = [ + 'fake3-firstTick-0', + 'fake3-simpleTick-1000', + 'fake3-simpleTick-2000' + ]; + + final actualLabelsWith10Diff = + formatter.format(ticksWith10Diff, formatterCache, stepSize: 10); + final actualLabelsWith20Diff = + formatter.format(ticksWith20Diff, formatterCache, stepSize: 20); + final actualLabelsWith100Diff = + formatter.format(ticksWith100Diff, formatterCache, stepSize: 100); + final actualLabelsWith200Diff = + formatter.format(ticksWith200Diff, formatterCache, stepSize: 200); + final actualLabelsWith1000Diff = + formatter.format(ticksWith1000Diff, formatterCache, stepSize: 1000); + + expect(actualLabelsWith10Diff, equals(expectedLabels10Diff)); + expect(actualLabelsWith20Diff, equals(expectedLabels20Diff)); + + expect(actualLabelsWith100Diff, equals(expectedLabels100Diff)); + expect(actualLabelsWith200Diff, equals(expectedLabels200Diff)); + expect(actualLabelsWith1000Diff, equals(expectedLabels1000Diff)); + }); + + test('with smallest interval when no smaller one exists', () { + final formatter = new DateTimeTickFormatter.withFormatters( + {10: timeFormatter1, 100: timeFormatter2}); + final formatterCache = {}; + + final ticks = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(1), + new DateTime.fromMillisecondsSinceEpoch(2) + ]; + final expectedLabels = [ + 'fake1-firstTick-0', + 'fake1-simpleTick-1', + 'fake1-simpleTick-2' + ]; + final actualLabels = formatter.format(ticks, formatterCache, stepSize: 1); + + expect(actualLabels, equals(expectedLabels)); + }); + + test('with smallest interval for single tick input', () { + final formatter = new DateTimeTickFormatter.withFormatters( + {10: timeFormatter1, 100: timeFormatter2}); + final formatterCache = {}; + + final ticks = [new DateTime.fromMillisecondsSinceEpoch(5000)]; + final expectedLabels = ['fake1-firstTick-5000']; + final actualLabels = formatter.format(ticks, formatterCache, stepSize: 0); + expect(actualLabels, equals(expectedLabels)); + }); + + test('on empty input doesnt break', () { + final formatter = + new DateTimeTickFormatter.withFormatters({10: timeFormatter1}); + final formatterCache = {}; + + final actualLabels = + formatter.format([], formatterCache, stepSize: 10); + expect(actualLabels, isEmpty); + }); + + test('that formats transition tick with transition format', () { + final timeFormatter = new FakeTimeTickFormatter('fake', + isTransitionFunction: (DateTime tickValue, _) => + tickValue.millisecondsSinceEpoch == 20); + final formatterCache = {}; + + final formatter = + new DateTimeTickFormatter.withFormatters({10: timeFormatter}); + + final ticks = [ + new DateTime.fromMillisecondsSinceEpoch(0), + new DateTime.fromMillisecondsSinceEpoch(10), + new DateTime.fromMillisecondsSinceEpoch(20), + new DateTime.fromMillisecondsSinceEpoch(30) + ]; + + final expectedLabels = [ + 'fake-firstTick-0', + 'fake-simpleTick-10', + 'fake-transitionTick-20', + 'fake-simpleTick-30' + ]; + final actualLabels = + formatter.format(ticks, formatterCache, stepSize: 10); + + expect(actualLabels, equals(expectedLabels)); + }); + }); + + group('check custom time tick formatters', () { + test('throws arugment error if time resolution key is not positive', () { + expect( + () => new DateTimeTickFormatter.withFormatters({0: timeFormatter1}), + throwsArgumentError); + expect( + () => new DateTimeTickFormatter.withFormatters({-1: timeFormatter1}), + throwsArgumentError); + }); + + test('throws argument error if formatters is null or empty', () { + expect(() => new DateTimeTickFormatter.withFormatters(null), + throwsArgumentError); + expect(() => new DateTimeTickFormatter.withFormatters({}), + throwsArgumentError); + }); + + test('throws arugment error if formatters are not sorted', () { + expect( + () => new DateTimeTickFormatter.withFormatters({ + 3: timeFormatter1, + 1: timeFormatter2, + 2: timeFormatter3, + }), + throwsArgumentError); + + expect( + () => new DateTimeTickFormatter.withFormatters({ + 1: timeFormatter1, + 3: timeFormatter2, + 2: timeFormatter3, + }), + throwsArgumentError); + + expect( + () => new DateTimeTickFormatter.withFormatters({ + 2: timeFormatter1, + 3: timeFormatter2, + 1: timeFormatter3, + }), + throwsArgumentError); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/axis/time/simple_date_time_factory.dart b/charts_common/test/chart/cartesian/axis/time/simple_date_time_factory.dart new file mode 100644 index 000000000..340ad685d --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/time/simple_date_time_factory.dart @@ -0,0 +1,42 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/common/date_time_factory.dart'; +import 'package:intl/intl.dart' show DateFormat; + +/// Returns DateTime for testing. +class SimpleDateTimeFactory implements DateTimeFactory { + const SimpleDateTimeFactory(); + + @override + DateTime createDateTimeFromMilliSecondsSinceEpoch( + int millisecondsSinceEpoch) => + new DateTime.fromMillisecondsSinceEpoch(millisecondsSinceEpoch); + + @override + DateTime createDateTime(int year, + [int month = 1, + int day = 1, + int hour = 0, + int minute = 0, + int second = 0, + int millisecond = 0, + int microsecond = 0]) => + new DateTime( + year, month, day, hour, minute, second, millisecond, microsecond); + + @override + DateFormat createDateFormat(String pattern) => new DateFormat(pattern); +} diff --git a/charts_common/test/chart/cartesian/axis/time/time_stepper_test.dart b/charts_common/test/chart/cartesian/axis/time/time_stepper_test.dart new file mode 100644 index 000000000..83c358d25 --- /dev/null +++ b/charts_common/test/chart/cartesian/axis/time/time_stepper_test.dart @@ -0,0 +1,436 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/cartesian/axis/time/date_time_extents.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/day_time_stepper.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/hour_time_stepper.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/minute_time_stepper.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/month_time_stepper.dart'; +import 'package:charts_common/src/chart/cartesian/axis/time/year_time_stepper.dart'; +import 'package:test/test.dart'; +import 'simple_date_time_factory.dart' show SimpleDateTimeFactory; + +const EPSILON = 0.001; + +void main() { + const dateTimeFactory = const SimpleDateTimeFactory(); + const millisecondsInHour = 3600 * 1000; + + setUp(() {}); + + group('Day time stepper', () { + test('get steps with 1 day increments', () { + final stepper = new DayTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 8, 20), end: new DateTime(2017, 8, 25)); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 8, 20), + new DateTime(2017, 8, 21), + new DateTime(2017, 8, 22), + new DateTime(2017, 8, 23), + new DateTime(2017, 8, 24), + new DateTime(2017, 8, 25), + ])); + }); + + test('get steps with 5 day increments', () { + final stepper = new DayTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 8, 10), + end: new DateTime(2017, 8, 26), + ); + + final stepIterable = stepper.getSteps(extent)..iterator.reset(5); + final steps = stepIterable.toList(); + + expect(steps.length, equals(4)); + // Note, this is because 5 day increments in a month is 1,6,11,16,21,26,31 + expect( + steps, + equals([ + new DateTime(2017, 8, 11), + new DateTime(2017, 8, 16), + new DateTime(2017, 8, 21), + new DateTime(2017, 8, 26), + ])); + }); + + test('step through daylight saving forward change', () { + final stepper = new DayTimeStepper(dateTimeFactory); + // DST for PST 2017 begin on March 12 + final extent = new DateTimeExtents( + start: new DateTime(2017, 3, 11), + end: new DateTime(2017, 3, 13), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(3)); + expect( + steps, + equals([ + new DateTime(2017, 3, 11), + new DateTime(2017, 3, 12), + new DateTime(2017, 3, 13), + ])); + }); + + test('step through daylight saving backward change', () { + final stepper = new DayTimeStepper(dateTimeFactory); + // DST for PST 2017 end on November 5 + final extent = new DateTimeExtents( + start: new DateTime(2017, 11, 4), + end: new DateTime(2017, 11, 6), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(3)); + expect( + steps, + equals([ + new DateTime(2017, 11, 4), + new DateTime(2017, 11, 5), + new DateTime(2017, 11, 6), + ])); + }); + }); + + group('Hour time stepper', () { + test('gets steps in 1 hour increments', () { + final stepper = new HourTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 8, 20, 10), + end: new DateTime(2017, 8, 20, 15), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 8, 20, 10), + new DateTime(2017, 8, 20, 11), + new DateTime(2017, 8, 20, 12), + new DateTime(2017, 8, 20, 13), + new DateTime(2017, 8, 20, 14), + new DateTime(2017, 8, 20, 15), + ])); + }); + + test('gets steps in 4 hour increments', () { + final stepper = new HourTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 8, 20, 10), + end: new DateTime(2017, 8, 21, 10), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(4); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 8, 20, 12), + new DateTime(2017, 8, 20, 16), + new DateTime(2017, 8, 20, 20), + new DateTime(2017, 8, 21, 0), + new DateTime(2017, 8, 21, 4), + new DateTime(2017, 8, 21, 8), + ])); + }); + + test('step through daylight saving forward change in 1 hour increments', + () { + final stepper = new HourTimeStepper(dateTimeFactory); + // DST for PST 2017 begin on March 12. At 2am clocks are turned to 3am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 3, 12, 0), + end: new DateTime(2017, 3, 12, 5), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(5)); + expect( + steps, + equals([ + new DateTime(2017, 3, 12, 0), + new DateTime(2017, 3, 12, 1), + new DateTime(2017, 3, 12, 3), + new DateTime(2017, 3, 12, 4), + new DateTime(2017, 3, 12, 5), + ])); + }); + + test('step through daylight saving backward change in 1 hour increments', + () { + final stepper = new HourTimeStepper(dateTimeFactory); + // DST for PST 2017 end on November 5. At 2am, clocks are turned to 1am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 11, 5, 0), + end: new DateTime(2017, 11, 5, 4), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(1); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 11, 5, 0), + new DateTime(2017, 11, 5, 0) + .add(new Duration(milliseconds: millisecondsInHour)), + new DateTime(2017, 11, 5, 0) + .add(new Duration(milliseconds: millisecondsInHour * 2)), + new DateTime(2017, 11, 5, 2), + new DateTime(2017, 11, 5, 3), + new DateTime(2017, 11, 5, 4), + ])); + }); + + test('step through daylight saving forward change in 4 hour increments', + () { + final stepper = new HourTimeStepper(dateTimeFactory); + // DST for PST 2017 begin on March 12. At 2am clocks are turned to 3am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 3, 12, 0), + end: new DateTime(2017, 3, 13, 0), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(4); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 3, 12, 4), + new DateTime(2017, 3, 12, 8), + new DateTime(2017, 3, 12, 12), + new DateTime(2017, 3, 12, 16), + new DateTime(2017, 3, 12, 20), + new DateTime(2017, 3, 13, 0), + ])); + }); + + test('step through daylight saving backward change in 4 hour increments', + () { + final stepper = new HourTimeStepper(dateTimeFactory); + // DST for PST 2017 end on November 5. + // At 2am, clocks are turned to 1am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 11, 5, 0), + end: new DateTime(2017, 11, 6, 0), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(4); + final steps = stepIterable.toList(); + + expect(steps.length, equals(7)); + expect( + steps, + equals([ + new DateTime(2017, 11, 5, 0) + .add(new Duration(milliseconds: millisecondsInHour)), + new DateTime(2017, 11, 5, 4), + new DateTime(2017, 11, 5, 8), + new DateTime(2017, 11, 5, 12), + new DateTime(2017, 11, 5, 16), + new DateTime(2017, 11, 5, 20), + new DateTime(2017, 11, 6, 0), + ])); + }); + }); + + group('Minute time stepper', () { + test('gets steps with 5 minute increments', () { + final stepper = new MinuteTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 8, 20, 3, 46), + end: new DateTime(2017, 8, 20, 4, 02), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(5); + final steps = stepIterable.toList(); + + expect(steps.length, equals(3)); + expect( + steps, + equals([ + new DateTime(2017, 8, 20, 3, 50), + new DateTime(2017, 8, 20, 3, 55), + new DateTime(2017, 8, 20, 4), + ])); + }); + + test('step through daylight saving forward change', () { + final stepper = new MinuteTimeStepper(dateTimeFactory); + // DST for PST 2017 begin on March 12. At 2am clocks are turned to 3am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 3, 12, 1, 40), + end: new DateTime(2017, 3, 12, 4, 02), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(15); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + new DateTime(2017, 3, 12, 1, 45), + new DateTime(2017, 3, 12, 3), + new DateTime(2017, 3, 12, 3, 15), + new DateTime(2017, 3, 12, 3, 30), + new DateTime(2017, 3, 12, 3, 45), + new DateTime(2017, 3, 12, 4), + ])); + }); + + test('steps correctly after daylight saving forward change', () { + final stepper = new MinuteTimeStepper(dateTimeFactory); + // DST for PST 2017 begin on March 12. At 2am clocks are turned to 3am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 3, 12, 3, 02), + end: new DateTime(2017, 3, 12, 4, 02), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(30); + final steps = stepIterable.toList(); + + expect(steps.length, equals(2)); + expect( + steps, + equals([ + new DateTime(2017, 3, 12, 3, 30), + new DateTime(2017, 3, 12, 4), + ])); + }); + + test('step through daylight saving backward change', () { + final stepper = new MinuteTimeStepper(dateTimeFactory); + // DST for PST 2017 end on November 5. + // At 2am, clocks are turned to 1am. + final extent = new DateTimeExtents( + start: new DateTime(2017, 11, 5) + .add(new Duration(hours: 1, minutes: 29)), + end: new DateTime(2017, 11, 5, 3, 02)); + final stepIterable = stepper.getSteps(extent)..iterator.reset(30); + final steps = stepIterable.toList(); + + expect(steps.length, equals(6)); + expect( + steps, + equals([ + // The first 1:30am + new DateTime(2017, 11, 5).add(new Duration(hours: 1, minutes: 30)), + // The 2nd 1am. + new DateTime(2017, 11, 5).add(new Duration(hours: 2)), + // The 2nd 1:30am + new DateTime(2017, 11, 5).add(new Duration(hours: 2, minutes: 30)), + // 2am + new DateTime(2017, 11, 5).add(new Duration(hours: 3)), + // 2:30am + new DateTime(2017, 11, 5).add(new Duration(hours: 3, minutes: 30)), + // 3am + new DateTime(2017, 11, 5, 3) + ])); + }); + }); + + group('Month time stepper', () { + test('steps crosses the year', () { + final stepper = new MonthTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 5), + end: new DateTime(2018, 9), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(4); + final steps = stepIterable.toList(); + + expect(steps.length, equals(4)); + expect( + steps, + equals([ + new DateTime(2017, 8), + new DateTime(2017, 12), + new DateTime(2018, 4), + new DateTime(2018, 8), + ])); + }); + + test('steps within one year', () { + final stepper = new MonthTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017, 1), + end: new DateTime(2017, 5), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(2); + final steps = stepIterable.toList(); + + expect(steps.length, equals(2)); + expect( + steps, + equals([ + new DateTime(2017, 2), + new DateTime(2017, 4), + ])); + }); + }); + + group('Year stepper', () { + test('steps in 10 year increments', () { + final stepper = new YearTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(2017), + end: new DateTime(2042), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(10); + final steps = stepIterable.toList(); + + expect(steps.length, equals(3)); + expect( + steps, + equals([ + new DateTime(2020), + new DateTime(2030), + new DateTime(2040), + ])); + }); + + test('steps through negative year', () { + final stepper = new YearTimeStepper(dateTimeFactory); + final extent = new DateTimeExtents( + start: new DateTime(-420), + end: new DateTime(240), + ); + final stepIterable = stepper.getSteps(extent)..iterator.reset(200); + final steps = stepIterable.toList(); + + expect(steps.length, equals(4)); + expect( + steps, + equals([ + new DateTime(-400), + new DateTime(-200), + new DateTime(0), + new DateTime(200), + ])); + }); + }); +} diff --git a/charts_common/test/chart/cartesian/cartesian_renderer_test.dart b/charts_common/test/chart/cartesian/cartesian_renderer_test.dart new file mode 100644 index 000000000..d55bff7e8 --- /dev/null +++ b/charts_common/test/chart/cartesian/cartesian_renderer_test.dart @@ -0,0 +1,288 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/cartesian_renderer.dart'; +import 'package:charts_common/src/chart/common/chart_canvas.dart'; +import 'package:charts_common/src/chart/common/datum_details.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/common/symbol_renderer.dart'; + +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +/// For testing viewport start / end. +class FakeCartesianRenderer extends BaseCartesianRenderer { + @override + List getNearestDatumDetailPerSeries(Point chartPoint) => + null; + + @override + void paint(ChartCanvas canvas, double animationPercent) {} + + @override + void update(List seriesList, bool isAnimating) {} + + @override + SymbolRenderer get symbolRenderer => null; +} + +class MockAxis extends Mock implements Axis {} + +void main() { + BaseCartesianRenderer renderer; + + setUp(() { + renderer = new FakeCartesianRenderer(); + }); + + group('find viewport start', () { + test('several domains are in the viewport', () { + final data = [0, 1, 2, 3, 4, 5, 6]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(0); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + when(axis.compareDomainValueToViewport(4)).thenReturn(0); + when(axis.compareDomainValueToViewport(5)).thenReturn(1); + when(axis.compareDomainValueToViewport(6)).thenReturn(1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(2)); + }); + + test('extents are all in the viewport, use the first domain', () { + // Start of viewport is the same as the start of the domain. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(0); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(0)); + }); + + test('is the first domain', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(0); + when(axis.compareDomainValueToViewport(1)).thenReturn(1); + when(axis.compareDomainValueToViewport(2)).thenReturn(1); + when(axis.compareDomainValueToViewport(3)).thenReturn(1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(0)); + }); + + test('is the last domain', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(-1); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(3)); + }); + + test('is the middle', () { + final data = [0, 1, 2, 3, 4, 5, 6]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(-1); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + when(axis.compareDomainValueToViewport(4)).thenReturn(1); + when(axis.compareDomainValueToViewport(5)).thenReturn(1); + when(axis.compareDomainValueToViewport(6)).thenReturn(1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(3)); + }); + + test('viewport is between data', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(1); + when(axis.compareDomainValueToViewport(3)).thenReturn(1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(1)); + }); + + // Error case, viewport shouldn't be set to the outside of the extents. + // We still want to provide a value. + test('all extents greater than viewport ', () { + // Return the right most value as start of viewport. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(-1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(3)); + }); + + // Error case, viewport shouldn't be set to the outside of the extents. + // We still want to provide a value. + test('all extents less than viewport ', () { + // Return the left most value as the start of the viewport. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(1); + + final start = renderer.findNearestViewportStart(axis, domainFn, data); + + expect(start, equals(0)); + }); + }); + + group('find viewport end', () { + test('several domains are in the viewport', () { + final data = [0, 1, 2, 3, 4, 5, 6]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(0); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + when(axis.compareDomainValueToViewport(4)).thenReturn(0); + when(axis.compareDomainValueToViewport(5)).thenReturn(1); + when(axis.compareDomainValueToViewport(6)).thenReturn(1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(4)); + }); + + test('extents are all in the viewport, use the last domain', () { + // Start of viewport is the same as the end of the domain. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(0); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(3)); + }); + + test('is the first domain', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(0); + when(axis.compareDomainValueToViewport(1)).thenReturn(1); + when(axis.compareDomainValueToViewport(2)).thenReturn(1); + when(axis.compareDomainValueToViewport(3)).thenReturn(1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(0)); + }); + + test('is the last domain', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(-1); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(3)); + }); + + test('is the middle', () { + final data = [0, 1, 2, 3, 4, 5, 6]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(-1); + when(axis.compareDomainValueToViewport(3)).thenReturn(0); + when(axis.compareDomainValueToViewport(4)).thenReturn(1); + when(axis.compareDomainValueToViewport(5)).thenReturn(1); + when(axis.compareDomainValueToViewport(6)).thenReturn(1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(3)); + }); + + test('viewport is between data', () { + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(0)).thenReturn(-1); + when(axis.compareDomainValueToViewport(1)).thenReturn(-1); + when(axis.compareDomainValueToViewport(2)).thenReturn(1); + when(axis.compareDomainValueToViewport(3)).thenReturn(1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(2)); + }); + + // Error case, viewport shouldn't be set to the outside of the extents. + // We still want to provide a value. + test('all extents greater than viewport ', () { + // Return the right most value as start of viewport. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(-1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(3)); + }); + + // Error case, viewport shouldn't be set to the outside of the extents. + // We still want to provide a value. + test('all extents less than viewport ', () { + // Return the left most value as the start of the viewport. + final data = [0, 1, 2, 3]; + final domainFn = (datum, int _) => data[datum]; + final axis = new MockAxis(); + when(axis.compareDomainValueToViewport(any)).thenReturn(1); + + final start = renderer.findNearestViewportEnd(axis, domainFn, data); + + expect(start, equals(0)); + }); + }); +} diff --git a/charts_common/test/chart/common/behavior/a11y/domain_a11y_explore_behavior_test.dart b/charts_common/test/chart/common/behavior/a11y/domain_a11y_explore_behavior_test.dart new file mode 100644 index 000000000..499847964 --- /dev/null +++ b/charts_common/test/chart/common/behavior/a11y/domain_a11y_explore_behavior_test.dart @@ -0,0 +1,232 @@ +import 'dart:math' show Rectangle; +import 'package:charts_common/src/chart/common/chart_context.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart'; +import 'package:charts_common/src/chart/cartesian/cartesian_chart.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockContext extends Mock implements ChartContext {} + +class MockAxis extends Mock implements Axis {} + +class FakeCartesianChart extends CartesianChart { + @override + Rectangle drawAreaBounds; + + @override + Axis domainAxis; + + void callFireOnPostprocess(List> seriesList) { + fireOnPostprocess(seriesList); + } +} + +void main() { + FakeCartesianChart chart; + DomainA11yExploreBehavior behavior; + MockAxis domainAxis; + + MutableSeries _series1; + final _s1D1 = new MyRow('s1d1', 11, 'a11yd1'); + final _s1D2 = new MyRow('s1d2', 12, 'a11yd2'); + final _s1D3 = new MyRow('s1d3', 13, 'a11yd3'); + + setUp(() { + chart = new FakeCartesianChart() + ..drawAreaBounds = new Rectangle(50, 20, 150, 80); + + behavior = new DomainA11yExploreBehavior( + vocalizationCallback: domainVocalization); + behavior.attachTo(chart); + + domainAxis = new MockAxis(); + _series1 = new MutableSeries(new Series( + id: 's1', + data: [_s1D1, _s1D2, _s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + )) + ..setAttr(domainAxisKey, domainAxis); + }); + + test('creates nodes for vertically drawn charts', () { + // A LTR chart + final context = new MockContext(); + when(context.rtl).thenReturn(false); + chart.context = context; + // Drawn vertically + chart.vertical = true; + // Set step size of 50, which should be the width of the bounding box + when(domainAxis.stepSize).thenReturn(50.0); + when(domainAxis.getLocation('s1d1')).thenReturn(75.0); + when(domainAxis.getLocation('s1d2')).thenReturn(125.0); + when(domainAxis.getLocation('s1d3')).thenReturn(175.0); + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([_series1]); + + final nodes = behavior.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(50, 20, 50, 80))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(100, 20, 50, 80))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(150, 20, 50, 80))); + }); + + test('creates nodes for vertically drawn RTL charts', () { + // A RTL chart + final context = new MockContext(); + when(context.rtl).thenReturn(true); + chart.context = context; + // Drawn vertically + chart.vertical = true; + // Set step size of 50, which should be the width of the bounding box + when(domainAxis.stepSize).thenReturn(50.0); + when(domainAxis.getLocation('s1d1')).thenReturn(175.0); + when(domainAxis.getLocation('s1d2')).thenReturn(125.0); + when(domainAxis.getLocation('s1d3')).thenReturn(75.0); + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([_series1]); + + final nodes = behavior.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(150, 20, 50, 80))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(100, 20, 50, 80))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(50, 20, 50, 80))); + }); + + test('creates nodes for horizontally drawn charts', () { + // A LTR chart + final context = new MockContext(); + when(context.rtl).thenReturn(false); + chart.context = context; + // Drawn horizontally + chart.vertical = false; + // Set step size of 20, which should be the height of the bounding box + when(domainAxis.stepSize).thenReturn(20.0); + when(domainAxis.getLocation('s1d1')).thenReturn(30.0); + when(domainAxis.getLocation('s1d2')).thenReturn(50.0); + when(domainAxis.getLocation('s1d3')).thenReturn(70.0); + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([_series1]); + + final nodes = behavior.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(50, 20, 150, 20))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(50, 40, 150, 20))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(50, 60, 150, 20))); + }); + + test('creates nodes for horizontally drawn RTL charts', () { + // A LTR chart + final context = new MockContext(); + when(context.rtl).thenReturn(true); + chart.context = context; + // Drawn horizontally + chart.vertical = false; + // Set step size of 20, which should be the height of the bounding box + when(domainAxis.stepSize).thenReturn(20.0); + when(domainAxis.getLocation('s1d1')).thenReturn(30.0); + when(domainAxis.getLocation('s1d2')).thenReturn(50.0); + when(domainAxis.getLocation('s1d3')).thenReturn(70.0); + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([_series1]); + + final nodes = behavior.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(50, 20, 150, 20))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(50, 40, 150, 20))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(50, 60, 150, 20))); + }); + + test('nodes ordered correctly with a series missing a domain', () { + // A LTR chart + final context = new MockContext(); + when(context.rtl).thenReturn(false); + chart.context = context; + // Drawn vertically + chart.vertical = true; + // Set step size of 50, which should be the width of the bounding box + when(domainAxis.stepSize).thenReturn(50.0); + when(domainAxis.getLocation('s1d1')).thenReturn(75.0); + when(domainAxis.getLocation('s1d2')).thenReturn(125.0); + when(domainAxis.getLocation('s1d3')).thenReturn(175.0); + // Create a series with a missing domain + final seriesWithMissingDomain = new MutableSeries(new Series( + id: 'm1', + data: [_s1D1, _s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + )) + ..setAttr(domainAxisKey, domainAxis); + + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([seriesWithMissingDomain, _series1]); + + final nodes = behavior.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(50, 20, 50, 80))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(100, 20, 50, 80))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(150, 20, 50, 80))); + }); + + test('creates nodes with minimum width', () { + // A behavior with minimum width of 50 + final behaviorWithMinWidth = + new DomainA11yExploreBehavior(minimumWidth: 50.0); + behaviorWithMinWidth.attachTo(chart); + + // A LTR chart + final context = new MockContext(); + when(context.rtl).thenReturn(false); + chart.context = context; + // Drawn vertically + chart.vertical = true; + // Return a step size of 20, which is less than the minimum width. + // Expect the results to use the minimum width of 50 instead. + when(domainAxis.stepSize).thenReturn(20.0); + when(domainAxis.getLocation('s1d1')).thenReturn(75.0); + when(domainAxis.getLocation('s1d2')).thenReturn(125.0); + when(domainAxis.getLocation('s1d3')).thenReturn(175.0); + // Call fire on post process for the behavior to get the series list. + chart.callFireOnPostprocess([_series1]); + + final nodes = behaviorWithMinWidth.createA11yNodes(); + + expect(nodes, hasLength(3)); + expect(nodes[0].label, equals('s1d1')); + expect(nodes[0].boundingBox, equals(new Rectangle(50, 20, 50, 80))); + expect(nodes[1].label, equals('s1d2')); + expect(nodes[1].boundingBox, equals(new Rectangle(100, 20, 50, 80))); + expect(nodes[2].label, equals('s1d3')); + expect(nodes[2].boundingBox, equals(new Rectangle(150, 20, 50, 80))); + }); +} + +class MyRow { + final String campaign; + final int count; + final String a11yDescription; + MyRow(this.campaign, this.count, this.a11yDescription); +} diff --git a/charts_common/test/chart/common/behavior/chart_behavior_test.dart b/charts_common/test/chart/common/behavior/chart_behavior_test.dart new file mode 100644 index 000000000..8fa897459 --- /dev/null +++ b/charts_common/test/chart/common/behavior/chart_behavior_test.dart @@ -0,0 +1,147 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/common/series_renderer.dart'; +import 'package:mockito/mockito.dart'; + +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/behavior/chart_behavior.dart'; +import 'package:test/test.dart'; + +class MockBehavior extends Mock implements ChartBehavior {} + +class ParentBehavior implements ChartBehavior { + final ChartBehavior child; + + ParentBehavior(this.child); + + String get role => null; + + @override + void attachTo(BaseChart chart) { + chart.addBehavior(child); + } + + @override + void removeFrom(BaseChart chart) { + chart.removeBehavior(child); + } +} + +class ConcreteChart extends BaseChart { + @override + SeriesRenderer makeDefaultRenderer() => null; +} + +void main() { + ConcreteChart chart; + MockBehavior namedBehavior; + MockBehavior unnamedBehavior; + + setUp(() { + chart = new ConcreteChart(); + + namedBehavior = new MockBehavior(); + when(namedBehavior.role).thenReturn('foo'); + + unnamedBehavior = new MockBehavior(); + when(unnamedBehavior.role).thenReturn(null); + }); + + group('Attach & Detach', () { + test('attach is called once', () { + chart.addBehavior(namedBehavior); + verify(namedBehavior.attachTo(chart)).called(1); + + verify(namedBehavior.role); + verifyNoMoreInteractions(namedBehavior); + }); + + test('deteach is called once', () { + chart.addBehavior(namedBehavior); + verify(namedBehavior.attachTo(chart)).called(1); + + chart.removeBehavior(namedBehavior); + verify(namedBehavior.removeFrom(chart)).called(1); + + verify(namedBehavior.role); + verifyNoMoreInteractions(namedBehavior); + }); + + test('detach is called when name is reused', () { + final otherBehavior = new MockBehavior(); + when(otherBehavior.role).thenReturn('foo'); + + chart.addBehavior(namedBehavior); + verify(namedBehavior.attachTo(chart)).called(1); + + chart.addBehavior(otherBehavior); + verify(namedBehavior.removeFrom(chart)).called(1); + verify(otherBehavior.attachTo(chart)).called(1); + + verify(namedBehavior.role); + verify(otherBehavior.role); + verifyNoMoreInteractions(namedBehavior); + verifyNoMoreInteractions(otherBehavior); + }); + + test('detach is not called when name is null', () { + chart.addBehavior(namedBehavior); + verify(namedBehavior.attachTo(chart)).called(1); + + chart.addBehavior(unnamedBehavior); + verify(unnamedBehavior.attachTo(chart)).called(1); + + verify(namedBehavior.role); + verify(unnamedBehavior.role); + verifyNoMoreInteractions(namedBehavior); + verifyNoMoreInteractions(unnamedBehavior); + }); + + test('detach is not called when name is different', () { + final otherBehavior = new MockBehavior(); + when(otherBehavior.role).thenReturn('bar'); + + chart.addBehavior(namedBehavior); + verify(namedBehavior.attachTo(chart)).called(1); + + chart.addBehavior(otherBehavior); + verify(otherBehavior.attachTo(chart)).called(1); + + verify(namedBehavior.role); + verify(otherBehavior.role); + verifyNoMoreInteractions(namedBehavior); + verifyNoMoreInteractions(otherBehavior); + }); + + test('behaviors are removed when chart is destroyed', () { + final parentBehavior = new ParentBehavior(unnamedBehavior); + + chart.addBehavior(parentBehavior); + // The parent should add the child behavoir. + verify(unnamedBehavior.attachTo(chart)).called(1); + + chart.destroy(); + + // The parent should remove the child behavior. + verify(unnamedBehavior.removeFrom(chart)).called(1); + + // Remove should only be called once and shouldn't trigger a concurrent + // modification exception. + verify(unnamedBehavior.role); + verifyNoMoreInteractions(unnamedBehavior); + }); + }); +} diff --git a/charts_common/test/chart/common/behavior/domain_highlighter_test.dart b/charts_common/test/chart/common/behavior/domain_highlighter_test.dart new file mode 100644 index 000000000..b5aa91696 --- /dev/null +++ b/charts_common/test/chart/common/behavior/domain_highlighter_test.dart @@ -0,0 +1,196 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/behavior/domain_highlighter.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/common/selection_model/selection_model.dart'; +import 'package:charts_common/src/common/material_palette.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockChart extends Mock implements BaseChart { + LifecycleListener lastListener; + + @override + addLifecycleListener(LifecycleListener listener) => lastListener = listener; + + @override + removeLifecycleListener(LifecycleListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + } +} + +class MockSelectionModel extends Mock implements SelectionModel { + SelectionModelListener lastListener; + + @override + addSelectionListener(SelectionModelListener listener) => + lastListener = listener; + + @override + removeSelectionListener(SelectionModelListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + } +} + +void main() { + MockChart _chart; + MockSelectionModel _selectionModel; + + MutableSeries _series1; + final _s1D1 = new MyRow('s1d1', 11); + final _s1D2 = new MyRow('s1d2', 12); + final _s1D3 = new MyRow('s1d3', 13); + + MutableSeries _series2; + final _s2D1 = new MyRow('s2d1', 21); + final _s2D2 = new MyRow('s2d2', 22); + final _s2D3 = new MyRow('s2d3', 23); + + _setupSelection(List selected) { + _series1.data.forEach((MyRow row) { + when(_selectionModel.isDatumSelected(_series1, row)) + .thenReturn(selected.contains(row)); + }); + _series2.data.forEach((MyRow row) { + when(_selectionModel.isDatumSelected(_series2, row)) + .thenReturn(selected.contains(row)); + }); + } + + setUp(() { + _chart = new MockChart(); + + _selectionModel = new MockSelectionModel(); + when(_chart.getSelectionModel(SelectionModelType.info)) + .thenReturn(_selectionModel); + + _series1 = new MutableSeries(new Series( + id: 's1', + data: [_s1D1, _s1D2, _s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.blue.shadeDefault)) + ..measureFn = (_, __) => 0.0; + + _series2 = new MutableSeries(new Series( + id: 's2', + data: [_s2D1, _s2D2, _s2D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.red.shadeDefault)) + ..measureFn = (_, __) => 0.0; + }); + + group('DomainHighligher', () { + test('darkens the selected bars', () { + // Setup + final behavior = new DomainHighlighter(SelectionModelType.info); + behavior.attachTo(_chart); + _setupSelection([_s1D2, _s2D2]); + final seriesList = [_series1, _series2]; + + // Act + _selectionModel.lastListener(_selectionModel); + verify(_chart.redraw(skipAnimation: true, skipLayout: true)); + _chart.lastListener.onPostprocess(seriesList); + + // Verify + final s1ColorFn = _series1.colorFn; + expect(s1ColorFn(_series1.data[0], 0), + equals(MaterialPalette.blue.shadeDefault)); + expect(s1ColorFn(_series1.data[1], 1), + equals(MaterialPalette.blue.shadeDefault.darker)); + expect(s1ColorFn(_series1.data[2], 2), + equals(MaterialPalette.blue.shadeDefault)); + + final s2ColorFn = _series2.colorFn; + expect(s2ColorFn(_series2.data[0], 0), + equals(MaterialPalette.red.shadeDefault)); + expect(s2ColorFn(_series2.data[1], 1), + equals(MaterialPalette.red.shadeDefault.darker)); + expect(s2ColorFn(_series2.data[2], 2), + equals(MaterialPalette.red.shadeDefault)); + }); + + test('listens to other selection models', () { + // Setup + final behavior = new DomainHighlighter(SelectionModelType.action); + when(_chart.getSelectionModel(SelectionModelType.action)) + .thenReturn(_selectionModel); + + // Act + behavior.attachTo(_chart); + + // Verify + verify(_chart.getSelectionModel(SelectionModelType.action)); + verifyNever(_chart.getSelectionModel(SelectionModelType.info)); + }); + + test('leaves everything alone with no selection', () { + // Setup + final behavior = new DomainHighlighter(SelectionModelType.info); + behavior.attachTo(_chart); + _setupSelection([]); + final seriesList = [_series1, _series2]; + + // Act + _selectionModel.lastListener(_selectionModel); + verify(_chart.redraw(skipAnimation: true, skipLayout: true)); + _chart.lastListener.onPostprocess(seriesList); + + // Verify + final s1ColorFn = _series1.colorFn; + expect(s1ColorFn(_series1.data[0], 0), + equals(MaterialPalette.blue.shadeDefault)); + expect(s1ColorFn(_series1.data[1], 1), + equals(MaterialPalette.blue.shadeDefault)); + expect(s1ColorFn(_series1.data[2], 2), + equals(MaterialPalette.blue.shadeDefault)); + + final s2ColorFn = _series2.colorFn; + expect(s2ColorFn(_series2.data[0], 0), + equals(MaterialPalette.red.shadeDefault)); + expect(s2ColorFn(_series2.data[1], 1), + equals(MaterialPalette.red.shadeDefault)); + expect(s2ColorFn(_series2.data[2], 2), + equals(MaterialPalette.red.shadeDefault)); + }); + + test('cleans up', () { + // Setup + final behavior = new DomainHighlighter(SelectionModelType.info); + behavior.attachTo(_chart); + _setupSelection([_s1D2, _s2D2]); + + // Act + behavior.removeFrom(_chart); + + // Verify + expect(_chart.lastListener, isNull); + expect(_selectionModel.lastListener, isNull); + }); + }); +} + +class MyRow { + final String campaign; + final int count; + MyRow(this.campaign, this.count); +} diff --git a/charts_common/test/chart/common/behavior/line_point_highlighter_test.dart b/charts_common/test/chart/common/behavior/line_point_highlighter_test.dart new file mode 100644 index 000000000..eff1e76e2 --- /dev/null +++ b/charts_common/test/chart/common/behavior/line_point_highlighter_test.dart @@ -0,0 +1,217 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/cartesian/cartesian_chart.dart'; +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/behavior/line_point_highlighter.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/common/selection_model/selection_model.dart'; +import 'package:charts_common/src/common/material_palette.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockChart extends Mock implements CartesianChart { + LifecycleListener lastListener; + + @override + addLifecycleListener(LifecycleListener listener) => lastListener = listener; + + @override + removeLifecycleListener(LifecycleListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + } + + @override + bool get vertical => true; +} + +class MockSelectionModel extends Mock implements SelectionModel { + SelectionModelListener lastListener; + + @override + addSelectionListener(SelectionModelListener listener) => + lastListener = listener; + + @override + removeSelectionListener(SelectionModelListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + } +} + +class MockNumericAxis extends Mock implements NumericAxis { + @override + getLocation(num domain) { + return 10.0; + } +} + +void main() { + MockChart _chart; + MockSelectionModel _selectionModel; + + MutableSeries _series1; + final _s1D1 = new MyRow(1, 11); + final _s1D2 = new MyRow(2, 12); + final _s1D3 = new MyRow(3, 13); + + MutableSeries _series2; + final _s2D1 = new MyRow(4, 21); + final _s2D2 = new MyRow(5, 22); + final _s2D3 = new MyRow(6, 23); + + _setupSelection(List selected) { + _series1.data.forEach((MyRow row) { + when(_selectionModel.isDatumSelected(_series1, row)) + .thenReturn(selected.contains(row)); + }); + _series2.data.forEach((MyRow row) { + when(_selectionModel.isDatumSelected(_series2, row)) + .thenReturn(selected.contains(row)); + }); + } + + setUp(() { + _chart = new MockChart(); + + _selectionModel = new MockSelectionModel(); + when(_chart.getSelectionModel(SelectionModelType.info)) + .thenReturn(_selectionModel); + + _series1 = new MutableSeries(new Series( + id: 's1', + data: [_s1D1, _s1D2, _s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.blue.shadeDefault)) + ..measureFn = (_, __) => 0.0; + + _series2 = new MutableSeries(new Series( + id: 's2', + data: [_s2D1, _s2D2, _s2D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.red.shadeDefault)) + ..measureFn = (_, __) => 0.0; + }); + + group('LinePointHighlighter', () { + test('highlights the selected points', () { + // Setup + final behavior = + new LinePointHighlighter(selectionModelType: SelectionModelType.info); + final tester = new LinePointHighlighterTester(behavior); + behavior.attachTo(_chart); + _setupSelection([_s1D2, _s2D2]); + + // Mock axes for returning fake domain locations. + Axis domainAxis = new MockNumericAxis(); + Axis primaryMeasureAxis = new MockNumericAxis(); + + _series1.setAttr(domainAxisKey, domainAxis); + _series1.setAttr(measureAxisKey, primaryMeasureAxis); + _series1.measureOffsetFn = (MyRow datum, int index) => 0.0; + + _series2.setAttr(domainAxisKey, domainAxis); + _series2.setAttr(measureAxisKey, primaryMeasureAxis); + _series2.measureOffsetFn = (MyRow datum, int index) => 0.0; + + final seriesList = [_series1, _series2]; + + // Act + _selectionModel.lastListener(_selectionModel); + verify(_chart.redraw(skipAnimation: true, skipLayout: true)); + _chart.lastListener.onPostprocess(seriesList); + _chart.lastListener.onAxisConfigured(); + + // Verify + expect(tester.getSelectionLength(), equals(2)); + + expect(tester.isDatumSelected(_series1.data[0]), equals(false)); + expect(tester.isDatumSelected(_series1.data[1]), equals(true)); + expect(tester.isDatumSelected(_series1.data[2]), equals(false)); + + expect(tester.isDatumSelected(_series2.data[0]), equals(false)); + expect(tester.isDatumSelected(_series2.data[1]), equals(true)); + expect(tester.isDatumSelected(_series2.data[2]), equals(false)); + }); + + test('listens to other selection models', () { + // Setup + final behavior = new LinePointHighlighter( + selectionModelType: SelectionModelType.action); + when(_chart.getSelectionModel(SelectionModelType.action)) + .thenReturn(_selectionModel); + + // Act + behavior.attachTo(_chart); + + // Verify + verify(_chart.getSelectionModel(SelectionModelType.action)); + verifyNever(_chart.getSelectionModel(SelectionModelType.info)); + }); + + test('leaves everything alone with no selection', () { + // Setup + final behavior = + new LinePointHighlighter(selectionModelType: SelectionModelType.info); + final tester = new LinePointHighlighterTester(behavior); + behavior.attachTo(_chart); + _setupSelection([]); + final seriesList = [_series1, _series2]; + + // Act + _selectionModel.lastListener(_selectionModel); + verify(_chart.redraw(skipAnimation: true, skipLayout: true)); + _chart.lastListener.onPostprocess(seriesList); + _chart.lastListener.onAxisConfigured(); + + // Verify + expect(tester.getSelectionLength(), equals(0)); + + expect(tester.isDatumSelected(_series1.data[0]), equals(false)); + expect(tester.isDatumSelected(_series1.data[1]), equals(false)); + expect(tester.isDatumSelected(_series1.data[2]), equals(false)); + + expect(tester.isDatumSelected(_series2.data[0]), equals(false)); + expect(tester.isDatumSelected(_series2.data[1]), equals(false)); + expect(tester.isDatumSelected(_series2.data[2]), equals(false)); + }); + + test('cleans up', () { + // Setup + final behavior = + new LinePointHighlighter(selectionModelType: SelectionModelType.info); + behavior.attachTo(_chart); + _setupSelection([_s1D2, _s2D2]); + + // Act + behavior.removeFrom(_chart); + + // Verify + expect(_chart.lastListener, isNull); + expect(_selectionModel.lastListener, isNull); + }); + }); +} + +class MyRow { + final int campaign; + final int count; + MyRow(this.campaign, this.count); +} diff --git a/charts_common/test/chart/common/behavior/range_annotation_test.dart b/charts_common/test/chart/common/behavior/range_annotation_test.dart new file mode 100644 index 000000000..4e3c7da7c --- /dev/null +++ b/charts_common/test/chart/common/behavior/range_annotation_test.dart @@ -0,0 +1,189 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; + +import 'package:charts_common/src/chart/cartesian/cartesian_chart.dart'; +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/axis/numeric_tick_provider.dart'; +import 'package:charts_common/src/chart/cartesian/axis/tick_formatter.dart'; +import 'package:charts_common/src/chart/cartesian/axis/linear/linear_scale.dart'; +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/behavior/range_annotation.dart'; +import 'package:charts_common/src/common/material_palette.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class ConcreteChart extends CartesianChart { + LifecycleListener lastListener; + + Axis _domainAxis = new ConcreteNumericAxis(); + + @override + addLifecycleListener(LifecycleListener listener) { + lastListener = listener; + super.addLifecycleListener(listener); + } + + @override + removeLifecycleListener(LifecycleListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + super.removeLifecycleListener(listener); + } + + @override + Axis get domainAxis => _domainAxis; +} + +class ConcreteNumericAxis extends Axis { + ConcreteNumericAxis() + : super( + tickProvider: new MockTickProvider(), + tickFormatter: new NumericTickFormatter(), + scale: new LinearScale(), + ); +} + +class MockTickProvider extends Mock implements NumericTickProvider {} + +void main() { + ConcreteChart _chart; + + Series _series1; + final _s1D1 = new MyRow(0, 11); + final _s1D2 = new MyRow(1, 12); + final _s1D3 = new MyRow(2, 13); + + Series _series2; + final _s2D1 = new MyRow(3, 21); + final _s2D2 = new MyRow(4, 22); + final _s2D3 = new MyRow(5, 23); + + List> _annotations1; + + List> _annotations2; + + setUp(() { + _chart = new ConcreteChart(); + + _series1 = new Series( + id: 's1', + data: [_s1D1, _s1D2, _s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.blue.shadeDefault); + + _series2 = new Series( + id: 's2', + data: [_s2D1, _s2D2, _s2D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => MaterialPalette.red.shadeDefault); + + _annotations1 = [ + new RangeAnnotationSegment(1, 2, RangeAnnotationAxisType.domain), + new RangeAnnotationSegment(4, 5, RangeAnnotationAxisType.domain, + color: MaterialPalette.gray.shade200), + ]; + + _annotations2 = [ + new RangeAnnotationSegment(1, 2, RangeAnnotationAxisType.domain), + new RangeAnnotationSegment(4, 5, RangeAnnotationAxisType.domain, + color: MaterialPalette.gray.shade200), + new RangeAnnotationSegment(8, 10, RangeAnnotationAxisType.domain, + color: MaterialPalette.gray.shade300), + ]; + }); + + group('RangeAnnotation', () { + test('renders the annotations', () { + // Setup + final behavior = new RangeAnnotation(_annotations1); + final tester = new RangeAnnotationTester(behavior); + behavior.attachTo(_chart); + + final seriesList = [_series1, _series2]; + + // Act + _chart.domainAxis.autoViewport = true; + _chart.domainAxis.resetDomains(); + _chart.draw(seriesList); + _chart.domainAxis.layout(new Rectangle(0, 0, 100, 100), + new Rectangle(0, 0, 100, 100)); + _chart.lastListener.onAxisConfigured(); + + // Verify + expect(_chart.domainAxis.getLocation(2), equals(40.0)); + tester.doesAnnotationExist(20.0, 40.0, MaterialPalette.gray.shade100); + expect( + tester.doesAnnotationExist(20.0, 40.0, MaterialPalette.gray.shade100), + equals(true)); + expect( + tester.doesAnnotationExist( + 80.0, 100.0, MaterialPalette.gray.shade200), + equals(true)); + }); + + test('extends the domain axis when annotations fall outside the range', () { + // Setup + final behavior = new RangeAnnotation(_annotations2); + final tester = new RangeAnnotationTester(behavior); + behavior.attachTo(_chart); + + final seriesList = [_series1, _series2]; + + // Act + _chart.domainAxis.autoViewport = true; + _chart.domainAxis.resetDomains(); + _chart.draw(seriesList); + _chart.domainAxis.layout(new Rectangle(0, 0, 100, 100), + new Rectangle(0, 0, 100, 100)); + _chart.lastListener.onAxisConfigured(); + + // Verify + expect(_chart.domainAxis.getLocation(2), equals(20.0)); + expect( + tester.doesAnnotationExist(10.0, 20.0, MaterialPalette.gray.shade100), + equals(true)); + expect( + tester.doesAnnotationExist(40.0, 50.0, MaterialPalette.gray.shade200), + equals(true)); + expect( + tester.doesAnnotationExist( + 80.0, 100.0, MaterialPalette.gray.shade300), + equals(true)); + }); + + test('cleans up', () { + // Setup + final behavior = new RangeAnnotation(_annotations2); + behavior.attachTo(_chart); + + // Act + behavior.removeFrom(_chart); + + // Verify + expect(_chart.lastListener, isNull); + }); + }); +} + +class MyRow { + final int campaign; + final int count; + MyRow(this.campaign, this.count); +} diff --git a/charts_common/test/chart/common/behavior/select_nearest_test.dart b/charts_common/test/chart/common/behavior/select_nearest_test.dart new file mode 100644 index 000000000..cbe79ed72 --- /dev/null +++ b/charts_common/test/chart/common/behavior/select_nearest_test.dart @@ -0,0 +1,399 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/behavior/select_nearest.dart'; +import 'package:charts_common/src/chart/common/datum_details.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/common/selection_model/selection_model.dart'; +import 'package:charts_common/src/common/gesture_listener.dart'; +import 'package:charts_common/src/data/series.dart'; + +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +class MockChart extends Mock implements BaseChart { + GestureListener lastListener; + + @override + GestureListener addGestureListener(GestureListener listener) { + lastListener = listener; + return listener; + } + + @override + void removeGestureListener(GestureListener listener) { + expect(listener, equals(lastListener)); + lastListener = null; + } +} + +class MockSelectionModel extends Mock implements SelectionModel {} + +void main() { + MockChart _chart; + MockSelectionModel _hoverSelectionModel; + MockSelectionModel _clickSelectionModel; + ImmutableSeries _series1; + ImmutableSeries _series2; + DatumDetails _details1; + DatumDetails _details1Series2; + DatumDetails _details2; + DatumDetails _details3; + + SelectNearest _makeBehavior( + SelectionModelType selectionModelType, SelectNearestTrigger eventTrigger, + {bool expandToDomain, bool selectClosestSeries}) { + SelectNearest behavior = new SelectNearest( + selectionModelType: selectionModelType, + expandToDomain: expandToDomain, + selectClosestSeries: selectClosestSeries, + eventTrigger: eventTrigger); + + behavior.attachTo(_chart); + + return behavior; + } + + _setupChart( + {Point forPoint, + bool isWithinRenderer, + List respondWithDetails}) { + if (isWithinRenderer != null) { + when(_chart.pointWithinRenderer(forPoint)).thenReturn(isWithinRenderer); + } + if (respondWithDetails != null) { + when(_chart.getNearestDatumDetailPerSeries(forPoint)) + .thenReturn(respondWithDetails); + } + } + + setUp(() { + _hoverSelectionModel = new MockSelectionModel(); + _clickSelectionModel = new MockSelectionModel(); + + _chart = new MockChart(); + when(_chart.getSelectionModel(SelectionModelType.info)) + .thenReturn(_hoverSelectionModel); + when(_chart.getSelectionModel(SelectionModelType.action)) + .thenReturn(_clickSelectionModel); + + _series1 = new MutableSeries(new Series( + id: 'mySeries1', + data: [], + domainFn: (_, __) {}, + measureFn: (_, __) {})); + + _details1 = new DatumDetails( + datum: 'myDatum1', + domain: 'myDomain1', + series: _series1, + domainDistance: 10.0, + measureDistance: 20.0); + _details2 = new DatumDetails( + datum: 'myDatum2', + domain: 'myDomain2', + series: _series1, + domainDistance: 10.0, + measureDistance: 20.0); + _details3 = new DatumDetails( + datum: 'myDatum3', + domain: 'myDomain3', + series: _series1, + domainDistance: 10.0, + measureDistance: 20.0); + + _series2 = new MutableSeries(new Series( + id: 'mySeries2', + data: [], + domainFn: (_, __) {}, + measureFn: (_, __) {})); + + _details1Series2 = new DatumDetails( + datum: 'myDatum1s2', + domain: 'myDomain1', + series: _series2, + domainDistance: 10.0, + measureDistance: 20.0); + }); + + group('SelectNearest trigger handling', () { + test('single series selects detail', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.hover, + expandToDomain: true, selectClosestSeries: true); + Point point = new Point(100.0, 100.0); + _setupChart( + forPoint: point, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + // Act + _chart.lastListener.onHover(point); + + // Validate + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details1.datum)], [_series1])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + // Shouldn't be listening to anything else. + expect(_chart.lastListener.onTap, isNull); + expect(_chart.lastListener.onDragStart, isNull); + }); + + test('can listen to tap', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.action, SelectNearestTrigger.tap, + expandToDomain: true, selectClosestSeries: true); + Point point = new Point(100.0, 100.0); + _setupChart( + forPoint: point, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + // Act + _chart.lastListener.onTapTest(point); + _chart.lastListener.onTap(point); + + // Validate + verify(_clickSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details1.datum)], [_series1])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + + test('can listen to drag', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.pressHold, + expandToDomain: true, selectClosestSeries: true); + + Point startPoint = new Point(100.0, 100.0); + _setupChart( + forPoint: startPoint, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + Point updatePoint1 = new Point(200.0, 100.0); + _setupChart( + forPoint: updatePoint1, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + Point updatePoint2 = new Point(300.0, 100.0); + _setupChart( + forPoint: updatePoint2, + isWithinRenderer: true, + respondWithDetails: [_details2]); + + Point endPoint = new Point(400.0, 100.0); + _setupChart( + forPoint: endPoint, + isWithinRenderer: true, + respondWithDetails: [_details3]); + + // Act + _chart.lastListener.onTapTest(startPoint); + _chart.lastListener.onDragStart(startPoint); + _chart.lastListener.onDragUpdate(updatePoint1, 1.0); + _chart.lastListener.onDragUpdate(updatePoint2, 1.0); + _chart.lastListener.onDragEnd(endPoint, 1.0, 0.0); + + // Validate + // details1 was tripped 2 times (startPoint & updatePoint1) + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details1.datum)], [_series1])).called(2); + // details2 was tripped for updatePoint2 + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details2.datum)], [_series1])); + // dragEnd deselects even though we are over details3. + verify(_hoverSelectionModel.updateSelection([], [])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + + test('can listen to drag after long press', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.longPressHold, + expandToDomain: true, selectClosestSeries: true); + + Point startPoint = new Point(100.0, 100.0); + _setupChart( + forPoint: startPoint, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + Point updatePoint1 = new Point(200.0, 100.0); + _setupChart( + forPoint: updatePoint1, + isWithinRenderer: true, + respondWithDetails: [_details2]); + + Point endPoint = new Point(400.0, 100.0); + _setupChart( + forPoint: endPoint, + isWithinRenderer: true, + respondWithDetails: [_details3]); + + // Act 1 + _chart.lastListener.onTapTest(startPoint); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + + // Act 2 + // verify no interaction yet. + _chart.lastListener.onLongPress(startPoint); + _chart.lastListener.onDragStart(startPoint); + _chart.lastListener.onDragUpdate(updatePoint1, 1.0); + _chart.lastListener.onDragEnd(endPoint, 1.0, 0.0); + + // Validate + // details1 was tripped 2 times (longPress & dragStart) + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details1.datum)], [_series1])).called(2); + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details2.datum)], [_series1])); + // dragEnd deselects even though we are over details3. + verify(_hoverSelectionModel.updateSelection([], [])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + + test('no trigger before long press', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.longPressHold, + expandToDomain: true, selectClosestSeries: true); + + Point startPoint = new Point(100.0, 100.0); + _setupChart( + forPoint: startPoint, + isWithinRenderer: true, + respondWithDetails: [_details1]); + + Point updatePoint1 = new Point(200.0, 100.0); + _setupChart( + forPoint: updatePoint1, + isWithinRenderer: true, + respondWithDetails: [_details2]); + + Point endPoint = new Point(400.0, 100.0); + _setupChart( + forPoint: endPoint, + isWithinRenderer: true, + respondWithDetails: [_details3]); + + // Act + _chart.lastListener.onTapTest(startPoint); + _chart.lastListener.onDragStart(startPoint); + _chart.lastListener.onDragUpdate(updatePoint1, 1.0); + _chart.lastListener.onDragEnd(endPoint, 1.0, 0.0); + + // Validate + // No interaction, didn't long press first. + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + }); + + group('Details', () { + test('expands to domain and includes closest series', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.hover, + expandToDomain: true, selectClosestSeries: true); + Point point = new Point(100.0, 100.0); + _setupChart(forPoint: point, isWithinRenderer: true, respondWithDetails: [ + _details1, + _details1Series2, + ]); + + // Act + _chart.lastListener.onHover(point); + + // Validate + verify(_hoverSelectionModel.updateSelection([ + new SeriesDatum(_series1, _details1.datum), + new SeriesDatum(_series2, _details1Series2.datum) + ], [ + _series1 + ])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + + test('does not expand to domain', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.hover, + expandToDomain: false, selectClosestSeries: true); + Point point = new Point(100.0, 100.0); + _setupChart(forPoint: point, isWithinRenderer: true, respondWithDetails: [ + _details1, + _details1Series2, + ]); + + // Act + _chart.lastListener.onHover(point); + + // Validate + verify(_hoverSelectionModel.updateSelection( + [new SeriesDatum(_series1, _details1.datum)], [_series1])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + + test('does not include closest series', () { + // Setup chart matches point with single domain single series. + _makeBehavior(SelectionModelType.info, SelectNearestTrigger.hover, + expandToDomain: true, selectClosestSeries: false); + Point point = new Point(100.0, 100.0); + _setupChart(forPoint: point, isWithinRenderer: true, respondWithDetails: [ + _details1, + _details1Series2, + ]); + + // Act + _chart.lastListener.onHover(point); + + // Validate + verify(_hoverSelectionModel.updateSelection([ + new SeriesDatum(_series1, _details1.datum), + new SeriesDatum(_series2, _details1Series2.datum) + ], [])); + verifyNoMoreInteractions(_hoverSelectionModel); + verifyNoMoreInteractions(_clickSelectionModel); + }); + }); + + group('Cleanup', () { + test('detach removes listener', () { + // Setup + SelectNearest behavior = _makeBehavior( + SelectionModelType.info, SelectNearestTrigger.hover, + expandToDomain: true, selectClosestSeries: true); + Point point = new Point(100.0, 100.0); + _setupChart( + forPoint: point, + isWithinRenderer: true, + respondWithDetails: [_details1]); + expect(_chart.lastListener, isNotNull); + + // Act + behavior.removeFrom(_chart); + + // Validate + expect(_chart.lastListener, isNull); + }); + }); +} diff --git a/charts_common/test/chart/common/behavior/series_legend_behavior_test.dart b/charts_common/test/chart/common/behavior/series_legend_behavior_test.dart new file mode 100644 index 000000000..92d82773c --- /dev/null +++ b/charts_common/test/chart/common/behavior/series_legend_behavior_test.dart @@ -0,0 +1,122 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/common/base_chart.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/common/series_renderer.dart'; +import 'package:charts_common/src/chart/common/behavior/legend/legend.dart'; +import 'package:charts_common/src/chart/common/selection_model/selection_model.dart'; +import 'package:charts_common/src/common/quantum_palette.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:test/test.dart'; + +class ConcreteChart extends BaseChart { + @override + SeriesRenderer makeDefaultRenderer() => null; + + void callOnPostProcess(List> seriesList) { + fireOnPostprocess(seriesList); + } +} + +void main() { + MutableSeries series1; + final s1D1 = new MyRow('s1d1', 11); + final s1D2 = new MyRow('s1d2', 12); + final s1D3 = new MyRow('s1d3', 13); + + MutableSeries series2; + final s2D1 = new MyRow('s2d1', 21); + final s2D2 = new MyRow('s2d2', 22); + final s2D3 = new MyRow('s2d3', 23); + + ConcreteChart chart; + + setUp(() { + chart = new ConcreteChart(); + + series1 = new MutableSeries(new Series( + id: 's1', + data: [s1D1, s1D2, s1D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => QuantumPalette.googleBlue.shadeDefault)) + ..measureFn = (_, __) => 0.0; + + series2 = new MutableSeries(new Series( + id: 's2', + data: [s2D1, s2D2, s2D3], + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.count, + colorFn: (_, __) => QuantumPalette.googleRed.shadeDefault)) + ..measureFn = (_, __) => 0.0; + }); + + test('Legend entries created on chart post process', () { + final seriesList = [series1, series2]; + final selectionType = SelectionModelType.info; + final legend = new SeriesLegend(selectionModelType: selectionType); + + legend.attachTo(chart); + chart.callOnPostProcess(seriesList); + + final legendEntries = legend.legendState.legendEntries; + expect(legendEntries, hasLength(2)); + expect(legendEntries[0].series, equals(series1)); + expect(legendEntries[0].label, equals('s1')); + expect( + legendEntries[0].color, equals(QuantumPalette.googleBlue.shadeDefault)); + expect(legendEntries[0].isSelected, isFalse); + + expect(legendEntries[1].series, equals(series2)); + expect(legendEntries[1].label, equals('s2')); + expect( + legendEntries[1].color, equals(QuantumPalette.googleRed.shadeDefault)); + expect(legendEntries[1].isSelected, isFalse); + }); + + test('selected series legend entry is updated', () { + final seriesList = [series1, series2]; + final selectionType = SelectionModelType.info; + final legend = new SeriesLegend(selectionModelType: selectionType); + + legend.attachTo(chart); + chart.callOnPostProcess(seriesList); + chart.getSelectionModel(selectionType).updateSelection([], [series1]); + + final selectedSeries = + legend.legendState.selectionModel.selectedSeries.first.id; + print('selected series $selectedSeries'); + final legendEntries = legend.legendState.legendEntries; + expect(legendEntries, hasLength(2)); + expect(legendEntries[0].series, equals(series1)); + expect(legendEntries[0].label, equals('s1')); + expect( + legendEntries[0].color, equals(QuantumPalette.googleBlue.shadeDefault)); + expect(legendEntries[0].isSelected, isTrue); + + expect(legendEntries[1].series, equals(series2)); + expect(legendEntries[1].label, equals('s2')); + expect( + legendEntries[1].color, equals(QuantumPalette.googleRed.shadeDefault)); + expect(legendEntries[1].isSelected, isFalse); + }); +} + +class MyRow { + final String campaign; + final int count; + MyRow(this.campaign, this.count); +} diff --git a/charts_common/test/chart/common/gesture_listener_test.dart b/charts_common/test/chart/common/gesture_listener_test.dart new file mode 100644 index 000000000..b7dd0733e --- /dev/null +++ b/charts_common/test/chart/common/gesture_listener_test.dart @@ -0,0 +1,249 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; +import 'package:charts_common/src/common/gesture_listener.dart'; +import 'package:charts_common/src/common/proxy_gesture_listener.dart'; +import 'package:test/test.dart'; + +void main() { + ProxyGestureListener _proxy; + Point _point; + setUp(() { + _proxy = new ProxyGestureListener(); + _point = new Point(10.0, 12.0); + }); + + group('Tap gesture', () { + test('notified for simple case', () { + // Setup + final tapListener = new MockListener(consumeEvent: true); + _proxy.listeners.add(new GestureListener(onTap: tapListener.callback)); + + // Act + _proxy.onTapTest(_point); + _proxy.onTap(_point); + + // Verify + tapListener.verify(arg1: _point); + }); + + test('notifies new listener for second event', () { + // Setup + final tapListener1 = new MockListener(); + _proxy.listeners.add(new GestureListener( + onTap: tapListener1.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onTap(_point); + + // Verify + tapListener1.verify(arg1: _point); + + // Setup Another + final tapListener2 = new MockListener(); + _proxy.listeners.add(new GestureListener( + onTap: tapListener2.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onTap(_point); + + // Verify + tapListener1.verify(callCount: 2, arg1: _point); + tapListener2.verify(arg1: _point); + }); + + test('notifies claiming listener registered first', () { + // Setup + final claimingTapDownListener = new MockListener(consumeEvent: true); + final claimingTapListener = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: claimingTapDownListener.callback, + onTap: claimingTapListener.callback, + )); + + final nonclaimingTapDownListener = new MockListener(consumeEvent: false); + final nonclaimingTapListener = new MockListener(consumeEvent: false); + + _proxy.listeners.add(new GestureListener( + onTapTest: nonclaimingTapDownListener.callback, + onTap: nonclaimingTapListener.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onTap(_point); + + // Verify + claimingTapDownListener.verify(arg1: _point); + claimingTapListener.verify(arg1: _point); + nonclaimingTapDownListener.verify(arg1: _point); + nonclaimingTapListener.verify(callCount: 0); + }); + + test('notifies claiming listener registered second', () { + // Setup + final nonclaimingTapDownListener = new MockListener(consumeEvent: false); + final nonclaimingTapListener = new MockListener(consumeEvent: false); + + _proxy.listeners.add(new GestureListener( + onTapTest: nonclaimingTapDownListener.callback, + onTap: nonclaimingTapListener.callback, + )); + + final claimingTapDownListener = new MockListener(consumeEvent: true); + final claimingTapListener = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: claimingTapDownListener.callback, + onTap: claimingTapListener.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onTap(_point); + + // Verify + nonclaimingTapDownListener.verify(arg1: _point); + nonclaimingTapListener.verify(callCount: 0); + claimingTapDownListener.verify(arg1: _point); + claimingTapListener.verify(arg1: _point); + }); + }); + + group('LongPress gesture', () { + test('notifies with tap', () { + // Setup + final tapDown = new MockListener(consumeEvent: true); + final tap = new MockListener(consumeEvent: true); + final tapCancel = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: tapDown.callback, + onTap: tap.callback, + onTapCancel: tapCancel.callback, + )); + + final pressTapDown = new MockListener(consumeEvent: true); + final longPress = new MockListener(consumeEvent: true); + final pressCancel = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: pressTapDown.callback, + onLongPress: longPress.callback, + onTapCancel: pressCancel.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onLongPress(_point); + _proxy.onTap(_point); + + // Verify + tapDown.verify(arg1: _point); + tap.verify(callCount: 0); + tapCancel.verify(callCount: 1); + + pressTapDown.verify(arg1: _point); + longPress.verify(arg1: _point); + pressCancel.verify(callCount: 0); + }); + }); + + group('Drag gesture', () { + test('wins over tap', () { + // Setup + final tapDown = new MockListener(consumeEvent: true); + final tap = new MockListener(consumeEvent: true); + final tapCancel = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: tapDown.callback, + onTap: tap.callback, + onTapCancel: tapCancel.callback, + )); + + final dragTapDown = new MockListener(consumeEvent: true); + final dragStart = new MockListener(consumeEvent: true); + final dragUpdate = new MockListener(consumeEvent: true); + final dragEnd = new MockListener(consumeEvent: true); + final dragCancel = new MockListener(consumeEvent: true); + + _proxy.listeners.add(new GestureListener( + onTapTest: dragTapDown.callback, + onDragStart: dragStart.callback, + onDragUpdate: dragUpdate.callback, + onDragEnd: dragEnd.callback, + onTapCancel: dragCancel.callback, + )); + + // Act + _proxy.onTapTest(_point); + _proxy.onDragStart(_point); + _proxy.onDragUpdate(_point, 1.0); + _proxy.onDragUpdate(_point, 1.0); + _proxy.onDragEnd(_point, 2.0, 3.0); + _proxy.onTap(_point); + + // Verify + tapDown.verify(arg1: _point); + tap.verify(callCount: 0); + tapCancel.verify(callCount: 1); + + dragTapDown.verify(arg1: _point); + dragStart.verify(arg1: _point); + dragUpdate.verify(callCount: 2, arg1: _point, arg2: 1.0); + dragEnd.verify(arg1: _point, arg2: 2.0, arg3: 3.0); + dragCancel.verify(callCount: 0); + }); + }); +} + +class MockListener { + Object _arg1; + Object _arg2; + Object _arg3; + int _callCount = 0; + + final bool consumeEvent; + + MockListener({this.consumeEvent = false}); + + bool callback([Object arg1, Object arg2, Object arg3]) { + _arg1 = arg1; + _arg2 = arg2; + _arg3 = arg3; + + _callCount++; + + return consumeEvent; + } + + verify({int callCount = 1, Object arg1, Object arg2, Object arg3}) { + if (callCount != any) { + expect(_callCount, equals(callCount)); + } + expect(_arg1, equals(arg1)); + expect(_arg2, equals(arg2)); + expect(_arg3, equals(arg3)); + } +} + +const any = -1; diff --git a/charts_common/test/chart/common/selection_model/selection_model_test.dart b/charts_common/test/chart/common/selection_model/selection_model_test.dart new file mode 100644 index 000000000..967cb6722 --- /dev/null +++ b/charts_common/test/chart/common/selection_model/selection_model_test.dart @@ -0,0 +1,261 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/common/selection_model/selection_model.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/data/series.dart'; +import 'package:test/test.dart'; + +void main() { + SelectionModel _selectionModel; + + ImmutableSeries _closestSeries; + MyDatum _closestDatumClosestSeries; + SeriesDatum _closestDatumClosestSeriesPair; + MyDatum _otherDatumClosestSeries; + SeriesDatum _otherDatumClosestSeriesPair; + + ImmutableSeries _otherSeries; + MyDatum _closestDatumOtherSeries; + SeriesDatum _closestDatumOtherSeriesPair; + MyDatum _otherDatumOtherSeries; + SeriesDatum _otherDatumOtherSeriesPair; + + setUp(() { + _selectionModel = new SelectionModel(); + + _closestDatumClosestSeries = new MyDatum('cDcS'); + _otherDatumClosestSeries = new MyDatum('oDcS'); + _closestSeries = new MutableSeries( + new Series( + id: 'closest', + data: [_closestDatumClosestSeries, _otherDatumClosestSeries], + domainFn: (MyDatum d, _) => d.id, + measureFn: (_, __) => 0)); + _closestDatumClosestSeriesPair = new SeriesDatum( + _closestSeries, _closestDatumClosestSeries); + _otherDatumClosestSeriesPair = new SeriesDatum( + _closestSeries, _otherDatumClosestSeries); + + _closestDatumOtherSeries = new MyDatum('cDoS'); + _otherDatumOtherSeries = new MyDatum('oDoS'); + _otherSeries = new MutableSeries( + new Series( + id: 'other', + data: [_closestDatumOtherSeries, _otherDatumOtherSeries], + domainFn: (MyDatum d, _) => d.id, + measureFn: (_, __) => 0)); + _closestDatumOtherSeriesPair = new SeriesDatum( + _otherSeries, _closestDatumOtherSeries); + _otherDatumOtherSeriesPair = + new SeriesDatum(_otherSeries, _otherDatumOtherSeries); + }); + + group('SelectionModel persists values', () { + test('selection model is empty by default', () { + expect(_selectionModel.hasDatumSelection, isFalse); + expect(_selectionModel.hasSeriesSelection, isFalse); + }); + + test('all datum are selected but only the first Series is', () { + // Select the 'closest' datum for each Series. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + new SeriesDatum(_otherSeries, _closestDatumOtherSeries), + ], [ + _closestSeries + ]); + + expect(_selectionModel.hasDatumSelection, isTrue); + expect(_selectionModel.selectedDatum, hasLength(2)); + expect(_selectionModel.selectedDatum, + contains(_closestDatumClosestSeriesPair)); + expect(_selectionModel.selectedDatum, + contains(_closestDatumOtherSeriesPair)); + expect( + _selectionModel.selectedDatum.contains(_otherDatumClosestSeriesPair), + isFalse); + expect(_selectionModel.selectedDatum.contains(_otherDatumOtherSeriesPair), + isFalse); + + expect(_selectionModel.hasSeriesSelection, isTrue); + expect(_selectionModel.selectedSeries, hasLength(1)); + expect(_selectionModel.selectedSeries, contains(_closestSeries)); + expect(_selectionModel.selectedSeries.contains(_otherSeries), isFalse); + }); + + test('selection can change', () { + // Select the 'closest' datum for each Series. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + new SeriesDatum(_otherSeries, _closestDatumOtherSeries), + ], [ + _closestSeries + ]); + + // Change selection to just the other datum on the other series. + _selectionModel.updateSelection([ + new SeriesDatum(_otherSeries, _otherDatumOtherSeries), + ], [ + _otherSeries + ]); + + expect(_selectionModel.selectedDatum, hasLength(1)); + expect( + _selectionModel.selectedDatum, contains(_otherDatumOtherSeriesPair)); + + expect(_selectionModel.selectedSeries, hasLength(1)); + expect(_selectionModel.selectedSeries, contains(_otherSeries)); + }); + + test('selection can be series only', () { + // Select the 'closest' Series without datum to simulate legend hovering. + _selectionModel.updateSelection([], [_closestSeries]); + + expect(_selectionModel.hasDatumSelection, isFalse); + expect(_selectionModel.selectedDatum, hasLength(0)); + + expect(_selectionModel.hasSeriesSelection, isTrue); + expect(_selectionModel.selectedSeries, hasLength(1)); + expect(_selectionModel.selectedSeries, contains(_closestSeries)); + }); + + test('selection lock prevents change', () { + // Prevent selection changes. + _selectionModel.locked = true; + + // Try to the 'closest' datum for each Series. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + new SeriesDatum(_otherSeries, _closestDatumOtherSeries), + ], [ + _closestSeries + ]); + + expect(_selectionModel.hasDatumSelection, isFalse); + expect(_selectionModel.hasSeriesSelection, isFalse); + + // Allow selection changes. + _selectionModel.locked = false; + + // Try to the 'closest' datum for each Series. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + new SeriesDatum(_otherSeries, _closestDatumOtherSeries), + ], [ + _closestSeries + ]); + + expect(_selectionModel.hasDatumSelection, isTrue); + expect(_selectionModel.hasSeriesSelection, isTrue); + + // Prevent selection changes. + _selectionModel.locked = true; + + // Attempt to change selection + _selectionModel.updateSelection([ + new SeriesDatum(_otherSeries, _otherDatumOtherSeries), + ], [ + _otherSeries + ]); + + // Previous selection should still be set. + expect(_selectionModel.selectedDatum, hasLength(2)); + expect(_selectionModel.selectedDatum, + contains(_closestDatumClosestSeriesPair)); + expect(_selectionModel.selectedDatum, + contains(_closestDatumOtherSeriesPair)); + + expect(_selectionModel.selectedSeries, hasLength(1)); + expect(_selectionModel.selectedSeries, contains(_closestSeries)); + }); + }); + + group('SelectionModel update listeners', () { + test('listener triggered for change', () { + SelectionModel triggeredModel; + // Listen + _selectionModel + .addSelectionListener((SelectionModel model) { + triggeredModel = model; + }); + + // Set the selection to closest datum. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + ], [ + _closestSeries + ]); + + // Callback should have been triggered. + expect(triggeredModel, equals(_selectionModel)); + }); + + test('listener not triggered for no change', () { + SelectionModel triggeredModel; + // Set the selection to closest datum. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + ], [ + _closestSeries + ]); + + // Listen + _selectionModel + .addSelectionListener((SelectionModel model) { + triggeredModel = model; + }); + + // Try to update the model with the same value. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + ], [ + _closestSeries + ]); + + // Callback should not have been triggered. + expect(triggeredModel, isNull); + }); + + test('removed listener not triggered for change', () { + SelectionModel triggeredModel; + + Function cb = (SelectionModel model) { + triggeredModel = model; + }; + + // Listen + _selectionModel.addSelectionListener(cb); + + // Unlisten + _selectionModel.removeSelectionListener(cb); + + // Set the selection to closest datum. + _selectionModel.updateSelection([ + new SeriesDatum(_closestSeries, _closestDatumClosestSeries), + ], [ + _closestSeries + ]); + + // Callback should not have been triggered. + expect(triggeredModel, isNull); + }); + }); +} + +class MyDatum { + final String id; + MyDatum(this.id); +} diff --git a/charts_common/test/chart/line/line_renderer_test.dart b/charts_common/test/chart/line/line_renderer_test.dart new file mode 100644 index 000000000..13053fbf4 --- /dev/null +++ b/charts_common/test/chart/line/line_renderer_test.dart @@ -0,0 +1,187 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/src/chart/line/line_renderer.dart'; +import 'package:charts_common/src/chart/line/line_renderer_config.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart' + show MutableSeries; +import 'package:charts_common/src/common/material_palette.dart' + show MaterialPalette; +import 'package:charts_common/src/data/series.dart' show Series; + +import 'package:test/test.dart'; + +/// Datum/Row for the chart. +class MyRow { + final String campaignString; + final int campaign; + final int clickCount; + MyRow(this.campaignString, this.campaign, this.clickCount); +} + +void main() { + LineRenderer renderer; + List> numericSeriesList; + List> ordinalSeriesList; + + setUp(() { + var myFakeDesktopData = [ + new MyRow('MyCampaign1', 1, 5), + new MyRow('MyCampaign2', 2, 25), + new MyRow('MyCampaign3', 3, 100), + new MyRow('MyOtherCampaign', 4, 75), + ]; + + var myFakeTabletData = [ + new MyRow('MyCampaign1', 1, 5), + new MyRow('MyCampaign2', 2, 25), + new MyRow('MyCampaign3', 3, 100), + new MyRow('MyOtherCampaign', 4, 75), + ]; + + var myFakeMobileData = [ + new MyRow('MyCampaign1', 1, 5), + new MyRow('MyCampaign2', 2, 25), + new MyRow('MyCampaign3', 3, 100), + new MyRow('MyOtherCampaign', 4, 75), + ]; + + numericSeriesList = [ + new MutableSeries(new Series( + id: 'Desktop', + colorFn: (_, __) => MaterialPalette.blue.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopData)), + new MutableSeries(new Series( + id: 'Tablet', + colorFn: (_, __) => MaterialPalette.red.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + strokeWidthPxFn: (MyRow row, _) => 1.25, + data: myFakeTabletData)), + new MutableSeries(new Series( + id: 'Mobile', + colorFn: (_, __) => MaterialPalette.green.shadeDefault, + domainFn: (MyRow row, _) => row.campaign, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + strokeWidthPxFn: (MyRow row, _) => 3.0, + data: myFakeMobileData)) + ]; + + ordinalSeriesList = [ + new MutableSeries(new Series( + id: 'Desktop', + colorFn: (_, __) => MaterialPalette.blue.shadeDefault, + domainFn: (MyRow row, _) => row.campaignString, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + data: myFakeDesktopData)), + new MutableSeries(new Series( + id: 'Tablet', + colorFn: (_, __) => MaterialPalette.red.shadeDefault, + domainFn: (MyRow row, _) => row.campaignString, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + strokeWidthPxFn: (MyRow row, _) => 1.25, + data: myFakeTabletData)), + new MutableSeries(new Series( + id: 'Mobile', + colorFn: (_, __) => MaterialPalette.green.shadeDefault, + domainFn: (MyRow row, _) => row.campaignString, + measureFn: (MyRow row, _) => row.clickCount, + measureOffsetFn: (MyRow row, _) => 0, + strokeWidthPxFn: (MyRow row, _) => 3.0, + data: myFakeMobileData)) + ]; + }); + + group('preprocess', () { + test('with numeric data and simple lines', () { + renderer = new LineRenderer( + config: new LineRendererConfig(strokeWidthPx: 2.0)); + + renderer.preprocessSeries(numericSeriesList); + + expect(numericSeriesList.length, equals(3)); + + // Validate Desktop series. + var series = numericSeriesList[0]; + + var elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + var element = elementsList[0]; + expect(element.strokeWidthPx, equals(2.0)); + + // Validate Tablet series. + series = numericSeriesList[1]; + + elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(1.25)); + + // Validate Mobile series. + series = numericSeriesList[2]; + + elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(3.0)); + }); + + test('with ordinal data and simple lines', () { + renderer = new LineRenderer( + config: new LineRendererConfig(strokeWidthPx: 2.0)); + + renderer.preprocessSeries(ordinalSeriesList); + + expect(ordinalSeriesList.length, equals(3)); + + // Validate Desktop series. + var series = ordinalSeriesList[0]; + + var elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + var element = elementsList[0]; + expect(element.strokeWidthPx, equals(2.0)); + + // Validate Tablet series. + series = ordinalSeriesList[1]; + + elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(1.25)); + + // Validate Mobile series. + series = ordinalSeriesList[2]; + + elementsList = series.getAttr(lineElementsKey); + expect(elementsList.length, equals(1)); + + element = elementsList[0]; + expect(element.strokeWidthPx, equals(3.0)); + }); + }); +} diff --git a/charts_common/test/chart/line/renderer_nearest_detail_test.dart b/charts_common/test/chart/line/renderer_nearest_detail_test.dart new file mode 100644 index 000000000..a7a98606c --- /dev/null +++ b/charts_common/test/chart/line/renderer_nearest_detail_test.dart @@ -0,0 +1,321 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math'; + +import 'package:charts_common/src/chart/cartesian/axis/axis.dart'; +import 'package:charts_common/src/chart/cartesian/cartesian_chart.dart'; +import 'package:charts_common/src/chart/common/chart_canvas.dart'; +import 'package:charts_common/src/chart/common/processed_series.dart'; +import 'package:charts_common/src/chart/line/line_renderer.dart'; +import 'package:charts_common/src/chart/line/line_renderer_config.dart'; +import 'package:charts_common/src/common/color.dart'; +import 'package:charts_common/src/data/series.dart'; + +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +/// Datum/Row for the chart. +class MyRow { + final int timestamp; + int clickCount; + MyRow(this.timestamp, this.clickCount); +} + +// TODO: Test in RTL context as well. + +class MockChart extends Mock implements CartesianChart {} + +class MockAxis extends Mock implements Axis {} + +class MockCanvas extends Mock implements ChartCanvas {} + +void main() { + ///////////////////////////////////////// + // Convenience methods for creating mocks. + ///////////////////////////////////////// + MutableSeries _makeSeries({String id, int measureOffset = 0}) { + final data = [ + new MyRow(1000, measureOffset + 10), + new MyRow(2000, measureOffset + 20), + new MyRow(3000, measureOffset + 30), + ]; + + final series = new MutableSeries(new Series( + id: id, + data: data, + domainFn: (MyRow row, _) => row.timestamp, + measureFn: (MyRow row, _) => row.clickCount, + )); + + series.measureOffsetFn = (_, __) => 0.0; + series.colorFn = (_, __) => new Color.fromHex(code: '#000000'); + + // Mock the Domain axis results. + final domainAxis = new MockAxis(); + when(domainAxis.rangeBand).thenReturn(100.0); + when(domainAxis.getLocation(1000)).thenReturn(70.0); + when(domainAxis.getLocation(2000)).thenReturn(70.0 + 100); + when(domainAxis.getLocation(3000)).thenReturn(70.0 + 200.0); + series.setAttr(domainAxisKey, domainAxis); + + // Mock the Measure axis results. + final measureAxis = new MockAxis(); + for (var i = 0; i <= 100; i++) { + when(measureAxis.getLocation(i.toDouble())) + .thenReturn(20.0 + 100.0 - i.toDouble()); + } + // Special case where measure is above drawArea. + when(measureAxis.getLocation(500)).thenReturn(20.0 + 100.0 - 500); + + series.setAttr(measureAxisKey, measureAxis); + + return series; + } + + LineRenderer renderer; + + setUp(() { + renderer = new LineRenderer( + config: new LineRendererConfig(strokeWidthPx: 1.0)); + final layoutBounds = new Rectangle(70, 20, 200, 100); + renderer.layout(layoutBounds, layoutBounds); + return renderer; + }); + + ///////////////////////////////////////// + // Additional edge test cases + ///////////////////////////////////////// + group('edge cases', () { + test('hit target with missing data in series still selects others', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..data.clear(), + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act Point just below barSeries.data[0] + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + + final closest = details[0]; + expect(closest.domain, equals(1000)); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(5)); + }); + + test('all series without data is skipped', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..data.clear(), + _makeSeries(id: 'bar')..data.clear(), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + + test('single overlay series is skipped', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..overlaySeries = true, + _makeSeries(id: 'bar'), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + + final closest = details[0]; + expect(closest.domain, equals(1000)); + expect(closest.series.id, equals('bar')); + expect(closest.datum, equals(seriesList[1].data[0])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(5)); + }); + + test('all overlay series is skipped', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..overlaySeries = true, + _makeSeries(id: 'bar')..overlaySeries = true, + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + }); + + ///////////////////////////////////////// + // Vertical BarRenderer + ///////////////////////////////////////// + group('LineRenderer', () { + test('hit test works', () { + // Setup + final seriesList = >[_makeSeries(id: 'foo')]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals(1000)); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(5)); + }); + + test('hit test expands to multiple series', () { + // Setup bar series is 20 measure higher than foo. + final seriesList = >[ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar', measureOffset: 20), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals(1000)); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[0])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(5)); + + final next = details[1]; + expect(next.domain, equals(1000)); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[0])); + expect(next.domainDistance, equals(10)); + expect(next.measureDistance, equals(25)); // 20offset + 10measure - 5pt + }); + + test('hit test expands with missing data in series', () { + // Setup bar series is 20 measure higher than foo and is missing the + // middle point. + final seriesList = >[ + _makeSeries(id: 'foo'), + _makeSeries(id: 'bar', measureOffset: 20)..data.removeAt(1), + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 100.0 + 10.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(2)); + + final closest = details[0]; + expect(closest.domain, equals(2000)); + expect(closest.series.id, equals('foo')); + expect(closest.datum, equals(seriesList[0].data[1])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(15)); + + // bar series jumps to last point since it is missing middle. + final next = details[1]; + expect(next.domain, equals(3000)); + expect(next.series.id, equals('bar')); + expect(next.datum, equals(seriesList[1].data[1])); + expect(next.domainDistance, equals(90)); + expect(next.measureDistance, equals(45.0)); + }); + + test('hit test works for points above drawArea', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..data[1].clickCount = 500 + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + final details = renderer.getNearestDatumDetailPerSeries( + new Point(70.0 + 100.0 + 10.0, 20.0 + 10.0)); + + // Verify + expect(details.length, equals(1)); + final closest = details[0]; + expect(closest.domain, equals(2000)); + expect(closest.series, equals(seriesList[0])); + expect(closest.datum, equals(seriesList[0].data[1])); + expect(closest.domainDistance, equals(10)); + expect(closest.measureDistance, equals(410)); // 500 - 100 + 10 + }); + + test('no selection for points outside of viewport', () { + // Setup + final seriesList = >[ + _makeSeries(id: 'foo')..data.add(new MyRow(-1000, 20)) + ]; + renderer.preprocessSeries(seriesList); + renderer.update(seriesList, false); + renderer.paint(new MockCanvas(), 1.0); + + // Act + // Note: point is in the axis, over a bar outside of the viewport. + final details = renderer.getNearestDatumDetailPerSeries( + new Point(-0.0, 20.0 + 100.0 - 5.0)); + + // Verify + expect(details.length, equals(0)); + }); + }); +} diff --git a/charts_flutter/README.md b/charts_flutter/README.md new file mode 100644 index 000000000..f2aaa7944 --- /dev/null +++ b/charts_flutter/README.md @@ -0,0 +1,3 @@ +# Flutter Charting library + +Material Design data visualization library written natively in Dart. diff --git a/charts_flutter/examples/ios/Flutter/AppFrameworkInfo.plist b/charts_flutter/examples/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 000000000..6c2de8086 --- /dev/null +++ b/charts_flutter/examples/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,30 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + UIRequiredDeviceCapabilities + + arm64 + + MinimumOSVersion + 8.0 + + diff --git a/charts_flutter/examples/ios/Flutter/Debug.xcconfig b/charts_flutter/examples/ios/Flutter/Debug.xcconfig new file mode 100644 index 000000000..592ceee85 --- /dev/null +++ b/charts_flutter/examples/ios/Flutter/Debug.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/charts_flutter/examples/ios/Flutter/Release.xcconfig b/charts_flutter/examples/ios/Flutter/Release.xcconfig new file mode 100644 index 000000000..592ceee85 --- /dev/null +++ b/charts_flutter/examples/ios/Flutter/Release.xcconfig @@ -0,0 +1 @@ +#include "Generated.xcconfig" diff --git a/charts_flutter/examples/ios/Runner.xcodeproj/project.pbxproj b/charts_flutter/examples/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 000000000..0c51fe592 --- /dev/null +++ b/charts_flutter/examples/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,436 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 2D5378261FAA1A9400D5DBA9 /* flutter_assets in Resources */ = {isa = PBXBuildFile; fileRef = 2D5378251FAA1A9400D5DBA9 /* flutter_assets */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; }; + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 3B80C3931E831B6300D905FE /* App.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; }; + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 9740EEBA1CF902C7004384FC /* Flutter.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB21CF90195004384FC /* Debug.xcconfig */; }; + 9740EEB51CF90195004384FC /* Generated.xcconfig in Resources */ = {isa = PBXBuildFile; fileRef = 9740EEB31CF90195004384FC /* Generated.xcconfig */; }; + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; + 97C146F31CF9000F007C117D /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 97C146F21CF9000F007C117D /* main.m */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + 3B80C3951E831B6300D905FE /* App.framework in Embed Frameworks */, + 9705A1C71CF904A300538489 /* Flutter.framework in Embed Frameworks */, + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2D5378251FAA1A9400D5DBA9 /* flutter_assets */ = {isa = PBXFileReference; lastKnownFileType = folder; name = flutter_assets; path = Flutter/flutter_assets; sourceTree = SOURCE_ROOT; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 3B80C3931E831B6300D905FE /* App.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = App.framework; path = Flutter/App.framework; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 9740EEBA1CF902C7004384FC /* Flutter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Flutter.framework; path = Flutter/Flutter.framework; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146F21CF9000F007C117D /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 9705A1C61CF904A100538489 /* Flutter.framework in Frameworks */, + 3B80C3941E831B6300D905FE /* App.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 2D5378251FAA1A9400D5DBA9 /* flutter_assets */, + 3B80C3931E831B6300D905FE /* App.framework */, + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEBA1CF902C7004384FC /* Flutter.framework */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + CF3B75C9A7D2FA2A4C99F110 /* Frameworks */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + 7AFFD8ED1D35381100E5BB4D /* AppDelegate.h */, + 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 97C146F11CF9000F007C117D /* Supporting Files */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + ); + path = Runner; + sourceTree = ""; + }; + 97C146F11CF9000F007C117D /* Supporting Files */ = { + isa = PBXGroup; + children = ( + 97C146F21CF9000F007C117D /* main.m */, + ); + name = "Supporting Files"; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 0910; + ORGANIZATIONNAME = "The Chromium Authors"; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = English; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 9740EEB51CF90195004384FC /* Generated.xcconfig in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 9740EEB41CF90195004384FC /* Debug.xcconfig in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 2D5378261FAA1A9400D5DBA9 /* flutter_assets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin"; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */, + 97C146F31CF9000F007C117D /* main.m in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 8.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ARCHS = arm64; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.example.examples; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/charts_flutter/examples/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/charts_flutter/examples/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..21a3cc14c --- /dev/null +++ b/charts_flutter/examples/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/charts_flutter/examples/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/charts_flutter/examples/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 000000000..89550f6ac --- /dev/null +++ b/charts_flutter/examples/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/charts_flutter/examples/ios/Runner/AppDelegate.h b/charts_flutter/examples/ios/Runner/AppDelegate.h new file mode 100644 index 000000000..3fbf7a5f6 --- /dev/null +++ b/charts_flutter/examples/ios/Runner/AppDelegate.h @@ -0,0 +1,22 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import + +@interface AppDelegate : UIResponder + +@property(strong, nonatomic) UIWindow *window; + +@end diff --git a/charts_flutter/examples/ios/Runner/AppDelegate.m b/charts_flutter/examples/ios/Runner/AppDelegate.m new file mode 100644 index 000000000..b87ca493f --- /dev/null +++ b/charts_flutter/examples/ios/Runner/AppDelegate.m @@ -0,0 +1,58 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "AppDelegate.h" +#import + +@implementation AppDelegate + +- (BOOL)application:(UIApplication *)application + didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { + // Override point for customization after application launch. + return YES; +} + +- (void)applicationWillResignActive:(UIApplication *)application { + // Sent when the application is about to move from active to inactive state. This can occur for + // certain types of temporary interruptions (such as an incoming phone call or SMS message) or + // when the user quits the application and it begins the transition to the background state. + // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame + // rates. Games should use this method to pause the game. +} + +- (void)applicationDidEnterBackground:(UIApplication *)application { + // Use this method to release shared resources, save user data, invalidate timers, and store + // enough application state information to restore your application to its current state in case + // it is terminated later. + // If your application supports background execution, this method is called instead of + // applicationWillTerminate: when the user quits. +} + +- (void)applicationWillEnterForeground:(UIApplication *)application { + // Called as part of the transition from the background to the inactive state; here you can undo + // many of the changes made on entering the background. +} + +- (void)applicationDidBecomeActive:(UIApplication *)application { + // Restart any tasks that were paused (or not yet started) while the application was inactive. + // If the application was previously in the background, optionally refresh the user interface. +} + +- (void)applicationWillTerminate:(UIApplication *)application { + // Called when the application is about to terminate. Save data if appropriate. See also + // applicationDidEnterBackground:. +} + +@end diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..033a07274 --- /dev/null +++ b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,82 @@ +{ + "images" : [ + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@2x.png", + "scale" : "2x" + }, + { + "size" : "29x29", + "idiom" : "iphone", + "filename" : "Icon-Small@3x.png", + "scale" : "3x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Small-40@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "iphone", + "filename" : "Icon-Small-40@3x.png", + "scale" : "3x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@2x.png", + "scale" : "2x" + }, + { + "size" : "60x60", + "idiom" : "iphone", + "filename" : "Icon-60@3x.png", + "scale" : "3x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small.png", + "scale" : "1x" + }, + { + "size" : "29x29", + "idiom" : "ipad", + "filename" : "Icon-Small@2x.png", + "scale" : "2x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Small-40.png", + "scale" : "1x" + }, + { + "size" : "40x40", + "idiom" : "ipad", + "filename" : "Icon-Small-40@2x.png", + "scale" : "2x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76.png", + "scale" : "1x" + }, + { + "size" : "76x76", + "idiom" : "ipad", + "filename" : "Icon-76@2x.png", + "scale" : "2x" + }, + { + "size" : "83.5x83.5", + "idiom" : "ipad", + "filename" : "Icon-83.5@2x.png", + "scale" : "2x" + } + ] +} \ No newline at end of file diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png new file mode 100644 index 000000000..c767e73ae Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@2x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png new file mode 100644 index 000000000..b41356db3 Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-60@3x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png new file mode 100644 index 000000000..de0a6dfbc Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png new file mode 100644 index 000000000..7c391945a Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-76@2x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png new file mode 100644 index 000000000..2f594d0e3 Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-83.5@2x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png new file mode 100644 index 000000000..31693a3d9 Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png new file mode 100644 index 000000000..fec316a13 Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@2x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png new file mode 100644 index 000000000..c767e73ae Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small-40@3x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png new file mode 100644 index 000000000..7abec8c8c Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png new file mode 100644 index 000000000..426123bcf Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@2x.png differ diff --git a/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png new file mode 100644 index 000000000..77f04fac4 Binary files /dev/null and b/charts_flutter/examples/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-Small@3x.png differ diff --git a/charts_flutter/examples/ios/Runner/Base.lproj/LaunchScreen.storyboard b/charts_flutter/examples/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 000000000..ebf48f603 --- /dev/null +++ b/charts_flutter/examples/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/charts_flutter/examples/ios/Runner/Base.lproj/Main.storyboard b/charts_flutter/examples/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 000000000..f3c28516f --- /dev/null +++ b/charts_flutter/examples/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/charts_flutter/examples/ios/Runner/Info.plist b/charts_flutter/examples/ios/Runner/Info.plist new file mode 100644 index 000000000..677c2dc52 --- /dev/null +++ b/charts_flutter/examples/ios/Runner/Info.plist @@ -0,0 +1,47 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Charts Flutter Gallery + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UIRequiredDeviceCapabilities + + arm64 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/charts_flutter/examples/ios/Runner/main.m b/charts_flutter/examples/ios/Runner/main.m new file mode 100644 index 000000000..8759ed8d7 --- /dev/null +++ b/charts_flutter/examples/ios/Runner/main.m @@ -0,0 +1,24 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#import +#import +#import "AppDelegate.h" + +int main(int argc, char* argv[]) { + @autoreleasepool { + return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); + } +} diff --git a/charts_flutter/examples/lib/a11y/a11y_gallery.dart b/charts_flutter/examples/lib/a11y/a11y_gallery.dart new file mode 100644 index 000000000..9e9b6a89d --- /dev/null +++ b/charts_flutter/examples/lib/a11y/a11y_gallery.dart @@ -0,0 +1,77 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import '../gallery_scaffold.dart'; +import 'domain_a11y_explore_bar_chart.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.accessibility), + title: 'Screen reader enabled bar chart', + subtitle: 'Requires TalkBack or Voiceover turned on to work. ' + 'Bar chart with domain selection explore mode behavior.', + childBuilder: (List series) => + new DomainA11yExploreBarChart(series), + seriesListBuilder: _createMultiSeriesWithMissingDomain, + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create one series with random data. +List> + _createMultiSeriesWithMissingDomain() { + final random = new Random(); + + final mobileData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tabletData = [ + // Purposely missing data to show that only measures that are available + // are vocalized. + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Mobile Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileData, + ), + new charts.Series( + id: 'Tablet Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletData, + ) + ]; +} diff --git a/charts_flutter/examples/lib/a11y/domain_a11y_explore_bar_chart.dart b/charts_flutter/examples/lib/a11y/domain_a11y_explore_bar_chart.dart new file mode 100644 index 000000000..36f6273ff --- /dev/null +++ b/charts_flutter/examples/lib/a11y/domain_a11y_explore_bar_chart.dart @@ -0,0 +1,164 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of a bar chart with domain selection A11y behavior. +/// +/// The OS screen reader (TalkBack / VoiceOver) setting must be turned on, or +/// the behavior does not do anything. +/// +/// Note that the screenshot does not show any visual differences but when the +/// OS screen reader is enabled, the node that is being read out loud will be +/// surrounded by a rectangle. +/// +/// When [DomainA11yExploreBehavior] is added to the chart, the chart will +/// listen for the gesture that triggers "explore mode". +/// "Explore mode" creates semantic nodes for each domain value in the chart +/// with a description (customizable, defaults to domain value) and a bounding +/// box that surrounds the domain. +/// +/// These semantic node descriptions are read out loud by the OS screen reader +/// when the user taps within the bounding box, or when the user cycles through +/// the screen's elements (such as swiping left and right). +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class DomainA11yExploreBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + DomainA11yExploreBarChart(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory DomainA11yExploreBarChart.withSampleData() { + return new DomainA11yExploreBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + /// An example of how to generate a customized vocalization for + /// [DomainA11yExploreBehavior] from a list of [SeriesDatum]s. + /// + /// The list of series datums is for one domain. + /// + /// This example vocalizes the domain, then for each series that has that + /// domain, it vocalizes the series display name and the measure and a + /// description of that measure. + String vocalizeDomainAndMeasures( + List> seriesDatums) { + final buffer = new StringBuffer(); + + buffer.write(seriesDatums.first.datum.year); + + for (charts.SeriesDatum seriesDatum in seriesDatums) { + final series = seriesDatum.series; + final datum = seriesDatum.datum; + + buffer.write(' ${series.displayName} ' + '${datum.sales / 1000} thousand dollars'); + } + + return buffer.toString(); + } + + @override + Widget build(BuildContext context) { + return new Semantics( + // Describe your chart + label: 'Yearly sales bar chart', + // Optionally provide a hint for the user to know how to trigger + // explore mode. + hint: 'Press and hold to enable explore', + child: new charts.BarChart( + seriesList, + animate: animate, + // To prevent conflict with the select nearest behavior that uses the + // tap gesture, turn off default interactions. + defaultInteractions: false, + behaviors: [ + new charts.DomainA11yExploreBehavior( + // Callback for generating the message that is vocalized. + // An example of how to use is in [vocalizeDomainAndMeasures]. + // If none is set, the default only vocalizes the domain value. + vocalizationCallback: vocalizeDomainAndMeasures, + // The following settings are optional, but shown here for + // demonstration purchases. + // [exploreModeTrigger] Default is press and hold, can be + // changed to tap. + exploreModeTrigger: charts.ExploreModeTrigger.pressHold, + // [exploreModeEnabledAnnouncement] Optionally notify the OS + // when explore mode is enabled. + exploreModeEnabledAnnouncement: 'Explore mode enabled', + // [exploreModeDisabledAnnouncement] Optionally notify the OS + // when explore mode is disabled. + exploreModeDisabledAnnouncement: 'Explore mode disabled', + // [minimumWidth] Default and minimum is 1.0. This is the + // minimum width of the screen reader bounding box. The bounding + // box width is calculated based on the domain axis step size. + // Minimum width will be used if the step size is smaller. + minimumWidth: 1.0, + ), + // Optionally include domain highlighter as a behavior. + // This behavior is included in this example to show that when an + // a11y node has focus, the chart's internal selection model is + // also updated. + new charts.DomainHighlighter(charts.SelectionModelType.info), + ], + )); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final mobileData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletData = [ + // Purposely missing data to show that only measures that are available + // are vocalized. + new OrdinalSales('2016', 25), + new OrdinalSales('2017', 50), + ]; + + return [ + new charts.Series( + id: 'Mobile Sales', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileData, + ), + new charts.Series( + id: 'Tablet Sales', + colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletData, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/app_config.dart b/charts_flutter/examples/lib/app_config.dart new file mode 100644 index 000000000..563c5c7c3 --- /dev/null +++ b/charts_flutter/examples/lib/app_config.dart @@ -0,0 +1,40 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; + +/// A particular configuration of the app. +class AppConfig { + final String appName; + final String appLink; + final ThemeData theme; + final bool showPerformanceOverlay; + + AppConfig( + {this.appName, this.appLink, this.theme, this.showPerformanceOverlay}); +} + +/// The default configuration of the app. +AppConfig get defaultConfig { + return new AppConfig( + appName: 'Charts Gallery', + appLink: '', + theme: new ThemeData( + brightness: Brightness.light, + primarySwatch: Colors.lightBlue, + ), + showPerformanceOverlay: false, + ); +} diff --git a/charts_flutter/examples/lib/axes/axes_gallery.dart b/charts_flutter/examples/lib/axes/axes_gallery.dart new file mode 100644 index 000000000..6fcda938e --- /dev/null +++ b/charts_flutter/examples/lib/axes/axes_gallery.dart @@ -0,0 +1,312 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import '../gallery_scaffold.dart'; +import 'bar_secondary_axis.dart'; +import 'bar_secondary_axis_only.dart'; +import 'horizontal_bar_secondary_axis.dart'; +import 'short_tick_length_axis.dart'; +import 'custom_font_size_and_color.dart'; +import 'measure_axis_label_alignment.dart'; +import 'hidden_ticks_and_labels_axis.dart'; +import 'custom_axis_tick_formatters.dart'; +import 'custom_measure_tick_count.dart'; +import 'integer_only_measure_axis.dart'; +import 'nonzero_bound_measure_axis.dart'; +import 'statically_provided_ticks.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Bar chart with Secondary Measure Axis', + subtitle: 'Bar chart with a series using a secondary measure axis', + childBuilder: (List series) => + new BarChartWithSecondaryAxis(series), + seriesListBuilder: _createSeriesWithSecondaryAxis, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Bar chart with Secondary Measure Axis only', + subtitle: 'Bar chart with both series using secondary measure axis', + childBuilder: (List series) => + new BarChartWithSecondaryAxisOnly(series), + seriesListBuilder: _createSeriesWithSecondaryAxisOnly, + ), + new GalleryScaffold( + listTileIcon: new Transform.rotate( + angle: 1.5708, child: new Icon(Icons.insert_chart)), + title: 'Horizontal bar chart with Secondary Measure Axis', + subtitle: + 'Horizontal Bar chart with a series using secondary measure axis', + childBuilder: (List series) => + new HorizontalBarChartWithSecondaryAxis(series), + seriesListBuilder: _createSeriesWithSecondaryAxis, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Short Ticks Axis', + subtitle: 'Bar chart with the primary measure axis having short ticks', + childBuilder: (List series) => + new ShortTickLengthAxis(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Custom Axis Fonts', + subtitle: 'Bar chart with custom axis font size and color', + childBuilder: (List series) => + new CustomFontSizeAndColor(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Label Alignment Axis', + subtitle: 'Bar chart with custom measure axis label alignments', + childBuilder: (List series) => + new MeasureAxisLabelAlignment(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'No Axis', + subtitle: 'Bar chart with only the axis line drawn', + childBuilder: (List series) => + new HiddenTicksAndLabelsAxis(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Statically Provided Ticks', + subtitle: 'Bar chart with statically provided ticks', + childBuilder: (List series) => + new StaticallyProvidedTicks(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Custom Formatter', + subtitle: 'Timeseries with custom domain and measure tick formatters', + childBuilder: (List series) => + new CustomAxisTickFormatters(series), + seriesListBuilder: _createDateTimeSales, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Custom Tick Count', + subtitle: 'Timeseries with custom measure axis tick count', + childBuilder: (List series) => + new CustomMeasureTickCount(series), + seriesListBuilder: _createDateTimeSales, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Integer Measure Ticks', + subtitle: 'Timeseries with only whole number measure axis ticks', + childBuilder: (List series) => + new IntegerOnlyMeasureAxis(series), + seriesListBuilder: _createDateTimeIntegers, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Non-zero bound Axis', + subtitle: 'Timeseries with measure axis that does not include zero', + childBuilder: (List series) => + new NonzeroBoundMeasureAxis(series), + seriesListBuilder: _createDateTimeLargeNumbers, + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create single series list. +List> _createSingleSeries() { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(1000)), + new OrdinalSales('2015', random.nextInt(1000)), + new OrdinalSales('2016', random.nextInt(1000)), + new OrdinalSales('2017', random.nextInt(1000)), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + ]; +} + +/// Create series list with multiple series. +List> _createSeriesWithSecondaryAxis() { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(1000)), + new OrdinalSales('2015', random.nextInt(1000)), + new OrdinalSales('2016', random.nextInt(1000)), + new OrdinalSales('2017', random.nextInt(1000)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(50)), + new OrdinalSales('2015', random.nextInt(50)), + new OrdinalSales('2016', random.nextInt(50)), + new OrdinalSales('2017', random.nextInt(50)), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + )..setAttribute(charts.measureAxisIdKey, 'secondaryMeasureAxisId'), + ]; +} + +/// Create series list with multiple series. +List> _createSeriesWithSecondaryAxisOnly() { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(1000)), + new OrdinalSales('2015', random.nextInt(1000)), + new OrdinalSales('2016', random.nextInt(1000)), + new OrdinalSales('2017', random.nextInt(1000)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(50)), + new OrdinalSales('2015', random.nextInt(50)), + new OrdinalSales('2016', random.nextInt(50)), + new OrdinalSales('2017', random.nextInt(50)), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + )..setAttribute(charts.measureAxisIdKey, 'secondaryMeasureAxisId'), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + )..setAttribute(charts.measureAxisIdKey, 'secondaryMeasureAxisId'), + ]; +} + +class DateTimeSales { + final DateTime timeStamp; + final int sales; + DateTimeSales(this.timeStamp, this.sales); +} + +List> _createDateTimeSales() { + final random = new Random(); + + var myFakeDesktopData = [ + new DateTimeSales(new DateTime(2017, 9, 25), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 26), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 27), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 28), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 29), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 30), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 01), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 02), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 03), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 04), random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 05), random.nextInt(35)), + ]; + return [ + new charts.Series( + id: 'Desktop', + domainFn: (DateTimeSales row, _) => row.timeStamp, + measureFn: (DateTimeSales row, _) => row.sales, + data: myFakeDesktopData), + ]; +} + +List> _createDateTimeIntegers() { + final random = new Random(); + + var myFakeDesktopData = [ + new DateTimeSales(new DateTime(2017, 9, 25), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 9, 26), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 9, 27), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 9, 28), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 9, 29), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 9, 30), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 10, 01), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 10, 02), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 10, 03), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 10, 04), random.nextDouble().round()), + new DateTimeSales(new DateTime(2017, 10, 05), random.nextDouble().round()), + ]; + return [ + new charts.Series( + id: 'Desktop', + domainFn: (DateTimeSales row, _) => row.timeStamp, + measureFn: (DateTimeSales row, _) => row.sales, + data: myFakeDesktopData), + ]; +} + +List> _createDateTimeLargeNumbers() { + final random = new Random(); + + var myFakeDesktopData = [ + new DateTimeSales(new DateTime(2017, 9, 25), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 26), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 27), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 28), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 29), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 9, 30), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 01), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 02), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 03), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 04), 100 + random.nextInt(35)), + new DateTimeSales(new DateTime(2017, 10, 05), 100 + random.nextInt(35)), + ]; + return [ + new charts.Series( + id: 'Desktop', + domainFn: (DateTimeSales row, _) => row.timeStamp, + measureFn: (DateTimeSales row, _) => row.sales, + data: myFakeDesktopData), + ]; +} diff --git a/charts_flutter/examples/lib/axes/bar_secondary_axis.dart b/charts_flutter/examples/lib/axes/bar_secondary_axis.dart new file mode 100644 index 000000000..31097924d --- /dev/null +++ b/charts_flutter/examples/lib/axes/bar_secondary_axis.dart @@ -0,0 +1,109 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using a primary and secondary axis (left & right respectively) +/// for a set of grouped bars. This is useful for comparing Series that have +/// different units (revenue vs clicks by region), or different magnitudes (2017 +/// revenue vs 1/1/2017 revenue by region). +/// +/// The first series plots using the primary axis to position its measure +/// values (bar height). This is the default axis used if the measureAxisId is +/// not set. +/// +/// The second series plots using the secondary axis due to the measureAxisId of +/// secondaryMeasureAxisId. +/// +/// Note: primary and secondary may flip left and right positioning when +/// RTL.flipAxisLocations is set. +class BarChartWithSecondaryAxis extends StatelessWidget { + static const secondaryMeasureAxisId = 'secondaryMeasureAxisId'; + final List seriesList; + final bool animate; + + BarChartWithSecondaryAxis(this.seriesList, {this.animate}); + + factory BarChartWithSecondaryAxis.withSampleData() { + return new BarChartWithSecondaryAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + // It is important when using both primary and secondary axes to choose + // the same number of ticks for both sides to get the gridlines to line + // up. + primaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(desiredTickCount: 3)), + secondaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(desiredTickCount: 3)), + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + final losAngelesSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + new charts.Series( + id: 'Los Angeles Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: losAngelesSalesData, + )..setAttribute(charts.measureAxisIdKey, secondaryMeasureAxisId) + // Set the 'Los Angeles Revenue' series to use the secondary measure axis. + // All series that have this set will use the secondary measure axis. + // All other series will use the primary measure axis. + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/bar_secondary_axis_only.dart b/charts_flutter/examples/lib/axes/bar_secondary_axis_only.dart new file mode 100644 index 000000000..edfd89a68 --- /dev/null +++ b/charts_flutter/examples/lib/axes/bar_secondary_axis_only.dart @@ -0,0 +1,79 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using only a secondary axis (on the right) for a set of grouped +/// bars. +/// +/// Both series plots using the secondary axis due to the measureAxisId of +/// secondaryMeasureAxisId. +/// +/// Note: secondary may flip left and right positioning when +/// RTL.flipAxisLocations is set. +class BarChartWithSecondaryAxisOnly extends StatelessWidget { + static const secondaryMeasureAxisId = 'secondaryMeasureAxisId'; + final List seriesList; + final bool animate; + + BarChartWithSecondaryAxisOnly(this.seriesList, {this.animate}); + + factory BarChartWithSecondaryAxisOnly.withSampleData() { + return new BarChartWithSecondaryAxisOnly( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 500), + new OrdinalSales('2015', 2500), + new OrdinalSales('2016', 1000), + new OrdinalSales('2017', 7500), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ) + // Set series to use the secondary measure axis. + ..setAttribute(charts.measureAxisIdKey, secondaryMeasureAxisId), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/custom_axis_tick_formatters.dart b/charts_flutter/examples/lib/axes/custom_axis_tick_formatters.dart new file mode 100644 index 000000000..6dd5d1fba --- /dev/null +++ b/charts_flutter/examples/lib/axes/custom_axis_tick_formatters.dart @@ -0,0 +1,89 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of timeseries chart with custom measure and domain formatters. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +class CustomAxisTickFormatters extends StatelessWidget { + final List seriesList; + final bool animate; + + CustomAxisTickFormatters(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory CustomAxisTickFormatters.withSampleData() { + return new CustomAxisTickFormatters( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart(seriesList, + animate: animate, + // Sets up a currency formatter for the measure axis. + primaryMeasureAxis: new charts.NumericAxisSpec( + tickFormatterSpec: new charts.BasicNumericTickFormatterSpec( + new NumberFormat.compactSimpleCurrency())), + + /// Customizes the date tick formatter. It will print the day of month + /// as the default format, but include the month and year if it + /// transitions to a new month. + /// + /// minute, hour, day, month, and year are all provided by default and + /// you can override them following this pattern. + domainAxis: new charts.DateTimeAxisSpec( + tickFormatterSpec: new charts.AutoDateTimeTickFormatterSpec( + day: new charts.TimeFormatterSpec( + format: 'd', transitionFormat: 'MM/dd/yyyy')))); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new MyRow(new DateTime(2017, 9, 25), 6), + new MyRow(new DateTime(2017, 9, 26), 8), + new MyRow(new DateTime(2017, 9, 27), 6), + new MyRow(new DateTime(2017, 9, 28), 9), + new MyRow(new DateTime(2017, 9, 29), 11), + new MyRow(new DateTime(2017, 9, 30), 15), + new MyRow(new DateTime(2017, 10, 01), 25), + new MyRow(new DateTime(2017, 10, 02), 33), + new MyRow(new DateTime(2017, 10, 03), 27), + new MyRow(new DateTime(2017, 10, 04), 31), + new MyRow(new DateTime(2017, 10, 05), 23), + ]; + + return [ + new charts.Series( + id: 'Cost', + domainFn: (MyRow row, _) => row.timeStamp, + measureFn: (MyRow row, _) => row.cost, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class MyRow { + final DateTime timeStamp; + final int cost; + MyRow(this.timeStamp, this.cost); +} diff --git a/charts_flutter/examples/lib/axes/custom_font_size_and_color.dart b/charts_flutter/examples/lib/axes/custom_font_size_and_color.dart new file mode 100644 index 000000000..5eddf77d6 --- /dev/null +++ b/charts_flutter/examples/lib/axes/custom_font_size_and_color.dart @@ -0,0 +1,103 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Custom Font Style Example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using a custom primary measure and domain axis replacing the +/// renderSpec with one with a custom font size and a custom color. +/// +/// There are many axis styling options in the SmallTickRenderer allowing you +/// to customize the font, tick lengths, and offsets. +class CustomFontSizeAndColor extends StatelessWidget { + final List seriesList; + final bool animate; + + CustomFontSizeAndColor(this.seriesList, {this.animate}); + + factory CustomFontSizeAndColor.withSampleData() { + return new CustomFontSizeAndColor( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + + /// Assign a custom style for the domain axis. + /// + /// This is an OrdinalAxisSpec to match up with BarChart's default + /// ordinal domain axis (use NumericAxisSpec or DateTimeAxisSpec for + /// other charts). + domainAxis: new charts.OrdinalAxisSpec( + renderSpec: new charts.SmallTickRendererSpec( + + // Tick and Label styling here. + labelStyle: new charts.TextStyleSpec( + fontSize: 18, // size in Pts. + color: charts.MaterialPalette.black), + + // Change the line colors to match text color. + lineStyle: new charts.LineStyleSpec( + color: charts.MaterialPalette.black))), + + /// Assign a custom style for the measure axis. + primaryMeasureAxis: new charts.NumericAxisSpec( + renderSpec: new charts.GridlineRendererSpec( + + // Tick and Label styling here. + labelStyle: new charts.TextStyleSpec( + fontSize: 18, // size in Pts. + color: charts.MaterialPalette.black), + + // Change the line colors to match text color. + lineStyle: new charts.LineStyleSpec( + color: charts.MaterialPalette.black))), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/custom_measure_tick_count.dart b/charts_flutter/examples/lib/axes/custom_measure_tick_count.dart new file mode 100644 index 000000000..1fabe7de6 --- /dev/null +++ b/charts_flutter/examples/lib/axes/custom_measure_tick_count.dart @@ -0,0 +1,82 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of timeseries chart with a custom number of ticks +/// +/// The tick count can be set by setting the [desiredMinTickCount] and +/// [desiredMaxTickCount] for automatically adjusted tick counts (based on +/// how 'nice' the ticks are) or [desiredTickCount] for a fixed tick count. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class CustomMeasureTickCount extends StatelessWidget { + final List seriesList; + final bool animate; + + CustomMeasureTickCount(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory CustomMeasureTickCount.withSampleData() { + return new CustomMeasureTickCount( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart(seriesList, + animate: animate, + + /// Customize the measure axis to have 2 ticks, + primaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(desiredTickCount: 2))); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new MyRow(new DateTime(2017, 9, 25), 6), + new MyRow(new DateTime(2017, 9, 26), 8), + new MyRow(new DateTime(2017, 9, 27), 6), + new MyRow(new DateTime(2017, 9, 28), 9), + new MyRow(new DateTime(2017, 9, 29), 11), + new MyRow(new DateTime(2017, 9, 30), 15), + new MyRow(new DateTime(2017, 10, 01), 25), + new MyRow(new DateTime(2017, 10, 02), 33), + new MyRow(new DateTime(2017, 10, 03), 27), + new MyRow(new DateTime(2017, 10, 04), 31), + new MyRow(new DateTime(2017, 10, 05), 23), + ]; + + return [ + new charts.Series( + id: 'Cost', + domainFn: (MyRow row, _) => row.timeStamp, + measureFn: (MyRow row, _) => row.cost, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class MyRow { + final DateTime timeStamp; + final int cost; + MyRow(this.timeStamp, this.cost); +} diff --git a/charts_flutter/examples/lib/axes/hidden_ticks_and_labels_axis.dart b/charts_flutter/examples/lib/axes/hidden_ticks_and_labels_axis.dart new file mode 100644 index 000000000..bcd7acff1 --- /dev/null +++ b/charts_flutter/examples/lib/axes/hidden_ticks_and_labels_axis.dart @@ -0,0 +1,85 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// No Axis Example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of hiding both axis. +class HiddenTicksAndLabelsAxis extends StatelessWidget { + final List seriesList; + final bool animate; + + HiddenTicksAndLabelsAxis(this.seriesList, {this.animate}); + + factory HiddenTicksAndLabelsAxis.withSampleData() { + return new HiddenTicksAndLabelsAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + + /// Assign a custom style for the measure axis. + /// + /// The NoneRenderSpec can still draw an axis line with + /// showAxisLine=true. + primaryMeasureAxis: + new charts.NumericAxisSpec(renderSpec: new charts.NoneRenderSpec()), + + /// This is an OrdinalAxisSpec to match up with BarChart's default + /// ordinal domain axis (use NumericAxisSpec or DateTimeAxisSpec for + /// other charts). + domainAxis: new charts.OrdinalAxisSpec( + // Make sure that we draw the domain axis line. + showAxisLine: true, + // But don't draw anything else. + renderSpec: new charts.NoneRenderSpec()), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/horizontal_bar_secondary_axis.dart b/charts_flutter/examples/lib/axes/horizontal_bar_secondary_axis.dart new file mode 100644 index 000000000..32135dc00 --- /dev/null +++ b/charts_flutter/examples/lib/axes/horizontal_bar_secondary_axis.dart @@ -0,0 +1,111 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using a primary and secondary axis (left & right respectively) +/// for a set of grouped bars. This is useful for comparing Series that have +/// different units (revenue vs clicks by region), or different magnitudes (2017 +/// revenue vs 1/1/2017 revenue by region). +/// +/// The first series plots using the primary axis to position its measure +/// values (bar height). This is the default axis used if the measureAxisId is +/// not set. +/// +/// The second series plots using the secondary axis due to the measureAxisId of +/// secondaryMeasureAxisId. +/// +/// Note: primary and secondary may flip left and right positioning when +/// RTL.flipAxisLocations is set. +class HorizontalBarChartWithSecondaryAxis extends StatelessWidget { + static const secondaryMeasureAxisId = 'secondaryMeasureAxisId'; + final List seriesList; + final bool animate; + + HorizontalBarChartWithSecondaryAxis(this.seriesList, {this.animate}); + + factory HorizontalBarChartWithSecondaryAxis.withSampleData() { + return new HorizontalBarChartWithSecondaryAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // For horizontal bar charts, set the [vertical] flag to false. + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + vertical: false, + // It is important when using both primary and secondary axes to choose + // the same number of ticks for both sides to get the gridlines to line + // up. + primaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(desiredTickCount: 3)), + secondaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(desiredTickCount: 3)), + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + final losAngelesSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + new charts.Series( + id: 'Los Angeles Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: losAngelesSalesData, + )..setAttribute(charts.measureAxisIdKey, secondaryMeasureAxisId) + // Set the 'Los Angeles Revenue' series to use the secondary measure axis. + // All series that have this set will use the secondary measure axis. + // All other series will use the primary measure axis. + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/integer_only_measure_axis.dart b/charts_flutter/examples/lib/axes/integer_only_measure_axis.dart new file mode 100644 index 000000000..04e3fb772 --- /dev/null +++ b/charts_flutter/examples/lib/axes/integer_only_measure_axis.dart @@ -0,0 +1,89 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of timeseries chart forcing the measure axis to have whole number +/// ticks. This is useful if the measure units don't make sense to present as +/// fractional. +/// +/// This is done by customizing the measure axis and setting +/// [dataIsInWholeNumbers] on the tick provider. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class IntegerOnlyMeasureAxis extends StatelessWidget { + final List seriesList; + final bool animate; + + IntegerOnlyMeasureAxis(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory IntegerOnlyMeasureAxis.withSampleData() { + return new IntegerOnlyMeasureAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart( + seriesList, + animate: animate, + // Provides a custom axis ensuring that the ticks are in whole numbers. + primaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: new charts.BasicNumericTickProviderSpec( + // Make sure we don't have values less than 1 as ticks + // (ie: counts). + dataIsInWholeNumbers: true, + // Fixed tick count to highlight the integer only behavior + // generating ticks [0, 1, 2, 3, 4]. + desiredTickCount: 5)), + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new MyRow(new DateTime(2017, 9, 25), 0), + new MyRow(new DateTime(2017, 9, 26), 0), + new MyRow(new DateTime(2017, 9, 27), 0), + new MyRow(new DateTime(2017, 9, 28), 0), + new MyRow(new DateTime(2017, 9, 29), 0), + new MyRow(new DateTime(2017, 9, 30), 0), + new MyRow(new DateTime(2017, 10, 01), 1), + new MyRow(new DateTime(2017, 10, 02), 1), + new MyRow(new DateTime(2017, 10, 03), 1), + new MyRow(new DateTime(2017, 10, 04), 1), + new MyRow(new DateTime(2017, 10, 05), 1), + ]; + + return [ + new charts.Series( + id: 'Headcount', + domainFn: (MyRow row, _) => row.timeStamp, + measureFn: (MyRow row, _) => row.headcount, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class MyRow { + final DateTime timeStamp; + final int headcount; + MyRow(this.timeStamp, this.headcount); +} diff --git a/charts_flutter/examples/lib/axes/measure_axis_label_alignment.dart b/charts_flutter/examples/lib/axes/measure_axis_label_alignment.dart new file mode 100644 index 000000000..2762d9eb8 --- /dev/null +++ b/charts_flutter/examples/lib/axes/measure_axis_label_alignment.dart @@ -0,0 +1,89 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Custom Tick Label Alignment Example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using a custom primary measure replacing the renderSpec with one +/// that aligns the text under the tick and left justifies. +class MeasureAxisLabelAlignment extends StatelessWidget { + final List seriesList; + final bool animate; + + MeasureAxisLabelAlignment(this.seriesList, {this.animate}); + + factory MeasureAxisLabelAlignment.withSampleData() { + return new MeasureAxisLabelAlignment( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + + /// Customize the primary measure axis using a small tick renderer. + /// Use String instead of num for ordinal domain axis + /// (typically bar charts). + primaryMeasureAxis: new charts.NumericAxisSpec( + renderSpec: new charts.GridlineRendererSpec( + // Display the measure axis labels below the gridline. + // + // 'Before' & 'after' follow the axis value direction. + // Vertical axes draw 'before' below & 'after' above the tick. + // Horizontal axes draw 'before' left & 'after' right the tick. + labelAnchor: charts.TickLabelAnchor.before, + + // Left justify the text in the axis. + // + // Note: outside means that the secondary measure axis would right + // justify. + labelJustification: charts.TickLabelJustification.outside, + )), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/nonzero_bound_measure_axis.dart b/charts_flutter/examples/lib/axes/nonzero_bound_measure_axis.dart new file mode 100644 index 000000000..65f1caa5e --- /dev/null +++ b/charts_flutter/examples/lib/axes/nonzero_bound_measure_axis.dart @@ -0,0 +1,79 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of timeseries chart that has a measure axis that does NOT include +/// zero. It starts at 100 and goes to 140. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class NonzeroBoundMeasureAxis extends StatelessWidget { + final List seriesList; + final bool animate; + + NonzeroBoundMeasureAxis(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory NonzeroBoundMeasureAxis.withSampleData() { + return new NonzeroBoundMeasureAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart(seriesList, + animate: animate, + // Provide a tickProviderSpec which does NOT require that zero is + // included. + primaryMeasureAxis: new charts.NumericAxisSpec( + tickProviderSpec: + new charts.BasicNumericTickProviderSpec(zeroBound: false))); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new MyRow(new DateTime(2017, 9, 25), 106), + new MyRow(new DateTime(2017, 9, 26), 108), + new MyRow(new DateTime(2017, 9, 27), 106), + new MyRow(new DateTime(2017, 9, 28), 109), + new MyRow(new DateTime(2017, 9, 29), 111), + new MyRow(new DateTime(2017, 9, 30), 115), + new MyRow(new DateTime(2017, 10, 01), 125), + new MyRow(new DateTime(2017, 10, 02), 133), + new MyRow(new DateTime(2017, 10, 03), 127), + new MyRow(new DateTime(2017, 10, 04), 131), + new MyRow(new DateTime(2017, 10, 05), 123), + ]; + + return [ + new charts.Series( + id: 'Headcount', + domainFn: (MyRow row, _) => row.timeStamp, + measureFn: (MyRow row, _) => row.headcount, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class MyRow { + final DateTime timeStamp; + final int headcount; + MyRow(this.timeStamp, this.headcount); +} diff --git a/charts_flutter/examples/lib/axes/short_tick_length_axis.dart b/charts_flutter/examples/lib/axes/short_tick_length_axis.dart new file mode 100644 index 000000000..198e20774 --- /dev/null +++ b/charts_flutter/examples/lib/axes/short_tick_length_axis.dart @@ -0,0 +1,82 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Custom Tick Style Example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of using a custom primary measure axis replacing the default +/// gridline rendering with a short tick rendering. It also turns on the axis +/// line so that the ticks have something to line up against. +/// +/// There are many axis styling options in the SmallTickRenderer allowing you +/// to customize the font, tick lengths, and offsets. +class ShortTickLengthAxis extends StatelessWidget { + final List seriesList; + final bool animate; + + ShortTickLengthAxis(this.seriesList, {this.animate}); + + factory ShortTickLengthAxis.withSampleData() { + return new ShortTickLengthAxis( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + + /// Customize the primary measure axis using a small tick renderer. + /// Note: use String instead of num for ordinal domain axis + /// (typically bar charts). + primaryMeasureAxis: new charts.NumericAxisSpec( + renderSpec: new charts.SmallTickRendererSpec( + // Tick and Label styling here. + )), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/axes/statically_provided_ticks.dart b/charts_flutter/examples/lib/axes/statically_provided_ticks.dart new file mode 100644 index 000000000..3b09871a9 --- /dev/null +++ b/charts_flutter/examples/lib/axes/statically_provided_ticks.dart @@ -0,0 +1,101 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of axis using statically provided ticks. +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of specifying a custom set of ticks to be used on the domain axis. +/// +/// Specifying custom set of ticks allows specifying exactly what ticks are +/// used in the axis. Each tick is also allowed to have a different style set. +/// +/// For an ordinal axis, the [StaticOrdinalTickProviderSpec] is shown in this +/// example defining ticks to be used with [TickSpec] of String. +/// +/// For numeric axis, the [StaticNumericTickProviderSpec] can be used by passing +/// in a list of ticks defined with [TickSpec] of num. +/// +/// For datetime axis, the [StaticDateTimeTickProviderSpec] can be used by +/// passing in a list of ticks defined with [TickSpec] of datetime. +class StaticallyProvidedTicks extends StatelessWidget { + final List seriesList; + final bool animate; + + StaticallyProvidedTicks(this.seriesList, {this.animate}); + + factory StaticallyProvidedTicks.withSampleData() { + return new StaticallyProvidedTicks( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // Create the ticks to be used the domain axis. + final staticTicks = >[ + new charts.TickSpec( + // Value must match the domain value. + '2014', + // Optional label for this tick, defaults to domain value if not set. + label: 'Year 2014', + // The styling for this tick. + style: new charts.TextStyleSpec( + color: new charts.Color(r: 0x4C, g: 0xAF, b: 0x50))), + // If no text style is specified - the style from renderSpec will be used + // if one is specified. + new charts.TickSpec('2015'), + new charts.TickSpec('2016'), + new charts.TickSpec('2017'), + ]; + + return new charts.BarChart( + seriesList, + animate: animate, + domainAxis: new charts.OrdinalAxisSpec( + tickProviderSpec: + new charts.StaticOrdinalTickProviderSpec(staticTicks)), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 25000), + new OrdinalSales('2016', 100000), + new OrdinalSales('2017', 750000), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/bar_gallery.dart b/charts_flutter/examples/lib/bar_chart/bar_gallery.dart new file mode 100644 index 000000000..db9f11bcb --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/bar_gallery.dart @@ -0,0 +1,401 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import '../gallery_scaffold.dart'; +import 'grouped.dart'; +import 'grouped_single_target_line.dart'; +import 'grouped_stacked.dart'; +import 'grouped_target_line.dart'; +import 'horizontal.dart'; +import 'horizontal_bar_label.dart'; +import 'horizontal_pattern_forward_hatch.dart'; +import 'pattern_forward_hatch.dart'; +import 'simple.dart'; +import 'stacked.dart'; +import 'stacked_horizontal.dart'; +import 'stacked_target_line.dart'; +import 'spark_bar.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Simple Bar Chart', + subtitle: 'Simple bar chart with a single series', + childBuilder: (List series) => new SimpleBarChart(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Stacked Bar Chart', + subtitle: 'Stacked bar chart with multiple series', + childBuilder: (List series) => new StackedBarChart(series), + seriesListBuilder: _createMultipleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Grouped Bar Chart', + subtitle: 'Grouped bar chart with multiple series', + childBuilder: (List series) => new GroupedBarChart(series), + seriesListBuilder: _createMultipleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Grouped Stacked Bar Chart', + subtitle: 'Grouped and stacked bar chart with multiple series', + childBuilder: (List series) => + new GroupedStackedBarChart(series), + seriesListBuilder: _createMultipleSeriesWithCategories, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Grouped Bar Target Line Chart', + subtitle: 'Grouped bar target line chart with multiple series', + childBuilder: (List series) => + new GroupedBarTargetLineChart(series), + seriesListBuilder: _createMultipleSeriesMultiTarget, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Grouped Bar Single Target Line Chart', + subtitle: + 'Grouped bar target line chart with multiple series and a single target', + childBuilder: (List series) => + new GroupedBarSingleTargetLineChart(series), + seriesListBuilder: _createMultipleSeriesSingleTarget, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Stacked Bar Target Line Chart', + subtitle: 'Stacked bar target line chart with multiple series', + childBuilder: (List series) => + new StackedBarTargetLineChart(series), + seriesListBuilder: _createMultipleSeriesMultiTarget, + ), + new GalleryScaffold( + listTileIcon: new Transform.rotate( + angle: 1.5708, child: new Icon(Icons.insert_chart)), + title: 'Horizontal Bar Chart', + subtitle: 'Horizontal bar chart with a single series', + childBuilder: (List series) => + new HorizontalBarChart(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Transform.rotate( + angle: 1.5708, child: new Icon(Icons.insert_chart)), + title: 'Stacked Horizontal Bar Chart', + subtitle: 'Stacked horizontal bar chart with multiple series', + childBuilder: (List series) => + new StackedHorizontalBarChart(series), + seriesListBuilder: _createMultipleSeries, + ), + new GalleryScaffold( + listTileIcon: new Transform.rotate( + angle: 1.5708, child: new Icon(Icons.insert_chart)), + title: 'Horizontal Bar Chart with Bar Labels', + subtitle: 'Horizontal bar chart with a single series and bar labels', + childBuilder: (List series) => + new HorizontalBarLabelChart(series), + seriesListBuilder: _createSingleSeriesWithLabelAccessor, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Spark Bar Chart', + subtitle: 'Spark Bar Chart', + childBuilder: (List series) => new SparkBar(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Pattern Forward Hatch Bar Chart', + subtitle: 'Pattern Forward Hatch Bar Chart', + childBuilder: (List series) => + new PatternForwardHatchBarChart(series), + seriesListBuilder: _createMultipleSeriesWithForwardHatchPattern, + ), + new GalleryScaffold( + listTileIcon: new Transform.rotate( + angle: 1.5708, child: new Icon(Icons.insert_chart)), + title: 'Horizontal Pattern Forward Hatch Bar Chart', + subtitle: 'Horizontal Pattern Forward Hatch Bar Chart', + childBuilder: (List series) => + new HorizontalPatternForwardHatchBarChart(series), + seriesListBuilder: _createMultipleSeriesWithForwardHatchPattern, + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createSingleSeries() { + final random = new Random(); + + final data = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +/// Create series list with multiple series. +List> _createMultipleSeries( + {String suffix = '', String rendererId}) { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Tablet${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Mobile${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + ]; +} + +List> _createMultipleSeriesMultiTarget() { + return _createMultipleSeries() + ..addAll(_createMultipleSeries( + suffix: '_Target', rendererId: 'customTargetLine')); +} + +List> _createMultipleSeriesSingleTarget() { + return _createMultipleSeries() + ..add(_createMultipleSeries( + suffix: '_Target', rendererId: 'customTargetLine')[0]); +} + +/// Create multiple series with categories. +/// +/// For group stacked bar charts. +List> + _createMultipleSeriesWithCategories() { + final random = new Random(); + + final desktopSalesDataA = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesDataA = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesDataA = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final desktopSalesDataB = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesDataB = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesDataB = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataA, + ), + new charts.Series( + id: 'Tablet A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataA, + ), + new charts.Series( + id: 'Mobile A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataA, + ), + new charts.Series( + id: 'Desktop B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataB, + ), + new charts.Series( + id: 'Tablet B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataB, + ), + new charts.Series( + id: 'Mobile B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataB, + ), + ]; +} + +/// Create one series with random data and a label accessor. +List> + _createSingleSeriesWithLabelAccessor() { + final random = new Random(); + + final data = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + labelAccessorFn: (OrdinalSales sales, _) => + '${sales.year}: \$${sales.sales.toString()}', + ) + ]; +} + +/// Create multiple series with a forward hatch pattern on the middle series. +List> + _createMultipleSeriesWithForwardHatchPattern( + {String suffix = '', String rendererId}) { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Tablet${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + fillPatternFn: (OrdinalSales sales, _) => + charts.FillPatternType.forwardHatch) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Mobile${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + ]; +} diff --git a/charts_flutter/examples/lib/bar_chart/grouped.dart b/charts_flutter/examples/lib/bar_chart/grouped.dart new file mode 100644 index 000000000..f70fb07a3 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/grouped.dart @@ -0,0 +1,95 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class GroupedBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + GroupedBarChart(this.seriesList, {this.animate}); + + factory GroupedBarChart.withSampleData() { + return new GroupedBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/grouped_single_target_line.dart b/charts_flutter/examples/lib/bar_chart/grouped_single_target_line.dart new file mode 100644 index 000000000..a65ea802a --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/grouped_single_target_line.dart @@ -0,0 +1,110 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class GroupedBarSingleTargetLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + GroupedBarSingleTargetLineChart(this.seriesList, {this.animate}); + + factory GroupedBarSingleTargetLineChart.withSampleData() { + return new GroupedBarSingleTargetLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart(seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + customSeriesRenderers: [ + new charts.BarTargetLineRendererConfig( + // ID used to link series to this renderer. + customRendererId: 'customTargetLine', + groupingType: charts.BarGroupingType.grouped) + ]); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final targetLineData = [ + new OrdinalSales('2014', 30), + new OrdinalSales('2015', 55), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 25), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData), + new charts.Series( + id: 'Desktop Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: targetLineData) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/grouped_stacked.dart b/charts_flutter/examples/lib/bar_chart/grouped_stacked.dart new file mode 100644 index 000000000..0387cf727 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/grouped_stacked.dart @@ -0,0 +1,140 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class GroupedStackedBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + GroupedStackedBarChart(this.seriesList, {this.animate}); + + factory GroupedStackedBarChart.withSampleData() { + return new GroupedStackedBarChart( + createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.groupedStacked, + ); + } + + /// Create series list with multiple series + static List> createSampleData() { + final desktopSalesDataA = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesDataA = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesDataA = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final desktopSalesDataB = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesDataB = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesDataB = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataA, + ), + new charts.Series( + id: 'Tablet A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataA, + ), + new charts.Series( + id: 'Mobile A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataA, + ), + new charts.Series( + id: 'Desktop B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataB, + ), + new charts.Series( + id: 'Tablet B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataB, + ), + new charts.Series( + id: 'Mobile B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataB, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/grouped_target_line.dart b/charts_flutter/examples/lib/bar_chart/grouped_target_line.dart new file mode 100644 index 000000000..5f3f2c421 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/grouped_target_line.dart @@ -0,0 +1,144 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class GroupedBarTargetLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + GroupedBarTargetLineChart(this.seriesList, {this.animate}); + + factory GroupedBarTargetLineChart.withSampleData() { + return new GroupedBarTargetLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart(seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + customSeriesRenderers: [ + new charts.BarTargetLineRendererConfig( + // ID used to link series to this renderer. + customRendererId: 'customTargetLine', + groupingType: charts.BarGroupingType.grouped) + ]); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final desktopTargetLineData = [ + new OrdinalSales('2014', 4), + new OrdinalSales('2015', 20), + new OrdinalSales('2016', 80), + new OrdinalSales('2017', 65), + ]; + + final tableTargetLineData = [ + new OrdinalSales('2014', 30), + new OrdinalSales('2015', 55), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 25), + ]; + + final mobileTargetLineData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 5), + new OrdinalSales('2016', 45), + new OrdinalSales('2017', 35), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Desktop Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + new charts.Series( + id: 'Tablet Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + new charts.Series( + id: 'Mobile Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/horizontal.dart b/charts_flutter/examples/lib/bar_chart/horizontal.dart new file mode 100644 index 000000000..4379ca661 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/horizontal.dart @@ -0,0 +1,71 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Horizontal bar chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class HorizontalBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + HorizontalBarChart(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory HorizontalBarChart.withSampleData() { + return new HorizontalBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // For horizontal bar charts, set the [vertical] flag to false. + return new charts.BarChart( + seriesList, + animate: animate, + vertical: false, + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/horizontal_bar_label.dart b/charts_flutter/examples/lib/bar_chart/horizontal_bar_label.dart new file mode 100644 index 000000000..5a2b34467 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/horizontal_bar_label.dart @@ -0,0 +1,88 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Horizontal bar chart with bar label renderer example and hidden domain axis. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class HorizontalBarLabelChart extends StatelessWidget { + final List seriesList; + final bool animate; + + HorizontalBarLabelChart(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory HorizontalBarLabelChart.withSampleData() { + return new HorizontalBarLabelChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + // [BarLabelDecorator] will automatically position the label + // inside the bar if the label will fit. If the label will not fit and the + // area outside of the bar is larger than the bar, it will draw outside of the + // bar. Labels can always display inside or outside using [LabelPosition]. + // + // Text style for inside / outside can be controlled independently by setting + // [insideLabelStyleSpec] and [outsideLabelStyleSpec]. + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + vertical: false, + // Set a bar label decorator. + // Example configuring different styles for inside/outside: + // barRendererDecorator: new Charts.BarLabelDecorator( + // insideLabelStyleSpec: new Charts.TextStyleSpec(...), + // outsideLabelStyleSpec: new Charts.TextStyleSpec(...)), + barRendererDecorator: new charts.BarLabelDecorator(), + // Hide domain axis. + domainAxis: + new charts.OrdinalAxisSpec(renderSpec: new charts.NoneRenderSpec()), + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + // Set a label accessor to control the text of the bar label. + labelAccessorFn: (OrdinalSales sales, _) => + '${sales.year}: \$${sales.sales.toString()}') + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/horizontal_pattern_forward_hatch.dart b/charts_flutter/examples/lib/bar_chart/horizontal_pattern_forward_hatch.dart new file mode 100644 index 000000000..6798aa240 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/horizontal_pattern_forward_hatch.dart @@ -0,0 +1,102 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Forward pattern hatch bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Forward hatch pattern horizontal bar chart example. +/// +/// The second series of bars is rendered with a pattern by defining a +/// fillPatternFn mapping function. +class HorizontalPatternForwardHatchBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + HorizontalPatternForwardHatchBarChart(this.seriesList, {this.animate}); + + factory HorizontalPatternForwardHatchBarChart.withSampleData() { + return new HorizontalPatternForwardHatchBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + vertical: false, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + fillPatternFn: (OrdinalSales sales, _) => + charts.FillPatternType.forwardHatch, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/pattern_forward_hatch.dart b/charts_flutter/examples/lib/bar_chart/pattern_forward_hatch.dart new file mode 100644 index 000000000..f053ffa70 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/pattern_forward_hatch.dart @@ -0,0 +1,100 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Forward hatch pattern bar chart example. +/// +/// The second series of bars is rendered with a pattern by defining a +/// fillPatternFn mapping function. +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class PatternForwardHatchBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + PatternForwardHatchBarChart(this.seriesList, {this.animate}); + + factory PatternForwardHatchBarChart.withSampleData() { + return new PatternForwardHatchBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + fillPatternFn: (OrdinalSales sales, _) => + charts.FillPatternType.forwardHatch, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/simple.dart b/charts_flutter/examples/lib/bar_chart/simple.dart new file mode 100644 index 000000000..e4e793a43 --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/simple.dart @@ -0,0 +1,70 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SimpleBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + SimpleBarChart(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory SimpleBarChart.withSampleData() { + return new SimpleBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/spark_bar.dart b/charts_flutter/examples/lib/bar_chart/spark_bar.dart new file mode 100644 index 000000000..258e2426c --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/spark_bar.dart @@ -0,0 +1,100 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Spark Bar Example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example of a Spark Bar by hiding both axis, reducing the chart margins. +class SparkBar extends StatelessWidget { + final List seriesList; + final bool animate; + + SparkBar(this.seriesList, {this.animate}); + + factory SparkBar.withSampleData() { + return new SparkBar( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + + /// Assign a custom style for the measure axis. + /// + /// The NoneRenderSpec only draws an axis line (and even that can be hidden + /// with showAxisLine=false). + primaryMeasureAxis: + new charts.NumericAxisSpec(renderSpec: new charts.NoneRenderSpec()), + + /// This is an OrdinalAxisSpec to match up with BarChart's default + /// ordinal domain axis (use NumericAxisSpec or DateTimeAxisSpec for + /// other charts). + domainAxis: new charts.OrdinalAxisSpec( + // Make sure that we draw the domain axis line. + showAxisLine: true, + // But don't draw anything else. + renderSpec: new charts.NoneRenderSpec()), + + // With a spark chart we likely don't want large chart margins. + // 1px is the smallest we can make each margin. + layoutConfig: new charts.LayoutConfig( + leftMarginSpec: new charts.MarginSpec.fixedPixel(0), + topMarginSpec: new charts.MarginSpec.fixedPixel(0), + rightMarginSpec: new charts.MarginSpec.fixedPixel(0), + bottomMarginSpec: new charts.MarginSpec.fixedPixel(0)), + ); + } + + /// Create series list with single series + static List> _createSampleData() { + final globalSalesData = [ + new OrdinalSales('2007', 3100), + new OrdinalSales('2008', 3500), + new OrdinalSales('2009', 5000), + new OrdinalSales('2010', 2500), + new OrdinalSales('2011', 3200), + new OrdinalSales('2012', 4500), + new OrdinalSales('2013', 4400), + new OrdinalSales('2014', 5000), + new OrdinalSales('2015', 5000), + new OrdinalSales('2016', 4500), + new OrdinalSales('2017', 4300), + ]; + + return [ + new charts.Series( + id: 'Global Revenue', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: globalSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/stacked.dart b/charts_flutter/examples/lib/bar_chart/stacked.dart new file mode 100644 index 000000000..ab00d532b --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/stacked.dart @@ -0,0 +1,96 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class StackedBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + StackedBarChart(this.seriesList, {this.animate}); + + /// Creates a stacked [BarChart] with sample data and no transition. + factory StackedBarChart.withSampleData() { + return new StackedBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.stacked, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/stacked_horizontal.dart b/charts_flutter/examples/lib/bar_chart/stacked_horizontal.dart new file mode 100644 index 000000000..ee6eed7fc --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/stacked_horizontal.dart @@ -0,0 +1,98 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class StackedHorizontalBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + StackedHorizontalBarChart(this.seriesList, {this.animate}); + + /// Creates a stacked [BarChart] with sample data and no transition. + factory StackedHorizontalBarChart.withSampleData() { + return new StackedHorizontalBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // For horizontal bar charts, set the [vertical] flag to false. + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.stacked, + vertical: false, + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/bar_chart/stacked_target_line.dart b/charts_flutter/examples/lib/bar_chart/stacked_target_line.dart new file mode 100644 index 000000000..f770e316a --- /dev/null +++ b/charts_flutter/examples/lib/bar_chart/stacked_target_line.dart @@ -0,0 +1,145 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class StackedBarTargetLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + StackedBarTargetLineChart(this.seriesList, {this.animate}); + + /// Creates a stacked [BarChart] with sample data and no transition. + factory StackedBarTargetLineChart.withSampleData() { + return new StackedBarTargetLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart(seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.stacked, + customSeriesRenderers: [ + new charts.BarTargetLineRendererConfig( + // ID used to link series to this renderer. + customRendererId: 'customTargetLine', + groupingType: charts.BarGroupingType.stacked) + ]); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final desktopTargetLineData = [ + new OrdinalSales('2014', 4), + new OrdinalSales('2015', 20), + new OrdinalSales('2016', 80), + new OrdinalSales('2017', 65), + ]; + + final tableTargetLineData = [ + new OrdinalSales('2014', 30), + new OrdinalSales('2015', 55), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 25), + ]; + + final mobileTargetLineData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 5), + new OrdinalSales('2016', 45), + new OrdinalSales('2017', 35), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Desktop Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + new charts.Series( + id: 'Tablet Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + new charts.Series( + id: 'Mobile Target Line', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileTargetLineData, + ) + // Configure our custom bar target renderer for this series. + ..setAttribute(charts.rendererIdKey, 'customTargetLine'), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/drawer.dart b/charts_flutter/examples/lib/drawer.dart new file mode 100644 index 000000000..ad2f68ac1 --- /dev/null +++ b/charts_flutter/examples/lib/drawer.dart @@ -0,0 +1,51 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; + +/// A menu drawer supporting toggling theme and performance overlay. +class GalleryDrawer extends StatelessWidget { + final bool showPerformanceOverlay; + final ValueChanged onShowPerformanceOverlayChanged; + + GalleryDrawer( + {Key key, + this.showPerformanceOverlay, + this.onShowPerformanceOverlayChanged}) + : super(key: key); + + @override + Widget build(BuildContext context) { + return new Drawer( + child: new ListView(children: [ + // Performance overlay toggle. + new ListTile( + leading: new Icon(Icons.assessment), + title: new Text('Performance Overlay'), + onTap: () { + onShowPerformanceOverlayChanged(!showPerformanceOverlay); + }, + selected: showPerformanceOverlay, + trailing: new Checkbox( + value: showPerformanceOverlay, + onChanged: (bool value) { + onShowPerformanceOverlayChanged(!showPerformanceOverlay); + }, + ), + ), + ]), + ); + } +} diff --git a/charts_flutter/examples/lib/gallery_scaffold.dart b/charts_flutter/examples/lib/gallery_scaffold.dart new file mode 100644 index 000000000..6c097d9b8 --- /dev/null +++ b/charts_flutter/examples/lib/gallery_scaffold.dart @@ -0,0 +1,79 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +typedef Widget GalleryWidgetBuilder(List seriesList); +typedef List SeriesListBuilder(); + +/// Helper to build gallery. +class GalleryScaffold extends StatefulWidget { + /// The widget used for leading in a [ListTile]. + final Widget listTileIcon; + final String title; + final String subtitle; + final GalleryWidgetBuilder childBuilder; + final SeriesListBuilder seriesListBuilder; + + GalleryScaffold( + {this.listTileIcon, + this.title, + this.subtitle, + this.childBuilder, + this.seriesListBuilder}); + + /// Gets the gallery + Widget buildGalleryListTile(BuildContext context) => new ListTile( + leading: listTileIcon, + title: new Text(title), + subtitle: new Text(subtitle), + onTap: () { + Navigator.push(context, new MaterialPageRoute(builder: (_) => this)); + }); + + @override + _GalleryScaffoldState createState() => new _GalleryScaffoldState(); +} + +class _GalleryScaffoldState extends State { + List seriesList; + + void _refreshData() { + seriesList = widget.seriesListBuilder(); + } + + void _handleButtonPress() { + setState(() => _refreshData()); + } + + @override + Widget build(BuildContext context) { + if (seriesList == null) { + _refreshData(); + } + + return new Scaffold( + appBar: new AppBar(title: new Text(widget.title)), + body: new Padding( + padding: const EdgeInsets.all(8.0), + child: new ListView(children: [ + new SizedBox(height: 250.0, child: widget.childBuilder(seriesList)), + ])), + floatingActionButton: new FloatingActionButton( + child: new Icon(Icons.refresh), onPressed: _handleButtonPress), + ); + } +} diff --git a/charts_flutter/examples/lib/home.dart b/charts_flutter/examples/lib/home.dart new file mode 100644 index 000000000..4533cf938 --- /dev/null +++ b/charts_flutter/examples/lib/home.dart @@ -0,0 +1,109 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import 'dart:developer'; +import 'app_config.dart'; +import 'drawer.dart'; +import 'a11y/a11y_gallery.dart' as a11y show buildGallery; +import 'bar_chart/bar_gallery.dart' as bar show buildGallery; +import 'time_series_chart/time_series_gallery.dart' as time_series + show buildGallery; +import 'line_chart/line_gallery.dart' as line show buildGallery; +import 'axes/axes_gallery.dart' as axes show buildGallery; +import 'interactions/interactions_gallery.dart' as interactions + show buildGallery; +import 'i18n/i18n_gallery.dart' as i18n show buildGallery; +import 'legends/legends_gallery.dart' as legends show buildGallery; + +/// Main entry point of the gallery app. +/// +/// This renders a list of all available demos. +class Home extends StatelessWidget { + final bool showPerformanceOverlay; + final ValueChanged onShowPerformanceOverlayChanged; + final a11yGalleries = a11y.buildGallery(); + final barGalleries = bar.buildGallery(); + final timeSeriesGalleries = time_series.buildGallery(); + final lineGalleries = line.buildGallery(); + final axesGalleries = axes.buildGallery(); + final interactionsGalleries = interactions.buildGallery(); + final i18nGalleries = i18n.buildGallery(); + final legendsGalleries = legends.buildGallery(); + + Home( + {Key key, + this.showPerformanceOverlay, + this.onShowPerformanceOverlayChanged}) + : super(key: key) { + assert(onShowPerformanceOverlayChanged != null); + } + + @override + Widget build(BuildContext context) { + var galleries = []; + + galleries.addAll( + a11yGalleries.map((gallery) => gallery.buildGalleryListTile(context))); + + // Add example bar charts. + galleries.addAll( + barGalleries.map((gallery) => gallery.buildGalleryListTile(context))); + + // Add example time series charts. + galleries.addAll(timeSeriesGalleries + .map((gallery) => gallery.buildGalleryListTile(context))); + + // Add example line charts. + galleries.addAll( + lineGalleries.map((gallery) => gallery.buildGalleryListTile(context))); + + // Add example custom axis. + galleries.addAll( + axesGalleries.map((gallery) => gallery.buildGalleryListTile(context))); + + galleries.addAll(interactionsGalleries + .map((gallery) => gallery.buildGalleryListTile(context))); + + // Add legends examples + galleries.addAll(legendsGalleries + .map((gallery) => gallery.buildGalleryListTile(context))); + + // Add examples for i18n. + galleries.addAll( + i18nGalleries.map((gallery) => gallery.buildGalleryListTile(context))); + + _setupPerformance(); + + return new Scaffold( + drawer: new GalleryDrawer( + showPerformanceOverlay: showPerformanceOverlay, + onShowPerformanceOverlayChanged: onShowPerformanceOverlayChanged), + appBar: new AppBar(title: new Text(defaultConfig.appName)), + body: new ListView(padding: kMaterialListPadding, children: galleries), + ); + } + + void _setupPerformance() { + // Change [printPerformance] to true and set the app to release mode to + // print performance numbers to console. By default, Flutter builds in debug + // mode and this mode is slow. To build in release mode, specify the flag + // blaze-run flag "--define flutter_build_mode=release". + // The build target must also be an actual device and not the emulator. + charts.Performance.time = (String tag) => Timeline.startSync(tag); + charts.Performance.timeEnd = (_) => Timeline.finishSync(); + } +} diff --git a/charts_flutter/examples/lib/i18n/i18n_gallery.dart b/charts_flutter/examples/lib/i18n/i18n_gallery.dart new file mode 100644 index 000000000..d586e542a --- /dev/null +++ b/charts_flutter/examples/lib/i18n/i18n_gallery.dart @@ -0,0 +1,163 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import '../gallery_scaffold.dart'; +import 'rtl_bar_chart.dart'; +import 'rtl_line_chart.dart'; +import 'rtl_series_legend.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'RTL Bar Chart', + subtitle: 'Simple bar chart in RTL', + childBuilder: (List series) => new RTLBarChart(series), + seriesListBuilder: _createSingleOrdinalSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'RTL Line Chart', + subtitle: 'Simple line chart in RTL', + childBuilder: (List series) => new RTLLineChart(series), + seriesListBuilder: _createNumericSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'RTL Series Legend', + subtitle: 'Series legend in RTL', + childBuilder: (List series) => new RTLSeriesLegend(series), + seriesListBuilder: _createMultipleSeries, + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createSingleOrdinalSeries() { + final random = new Random(); + + final data = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createNumericSingleSeries() { + final random = new Random(); + + final data = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +/// Create series list with multiple series. +List> _createMultipleSeries( + {String suffix = ''}) { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData), + new charts.Series( + id: 'Tablet${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData), + new charts.Series( + id: 'Mobile${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData), + new charts.Series( + id: 'Other${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData), + ]; +} diff --git a/charts_flutter/examples/lib/i18n/rtl_bar_chart.dart b/charts_flutter/examples/lib/i18n/rtl_bar_chart.dart new file mode 100644 index 000000000..81040f963 --- /dev/null +++ b/charts_flutter/examples/lib/i18n/rtl_bar_chart.dart @@ -0,0 +1,86 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// RTL Bar chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class RTLBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + RTLBarChart(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory RTLBarChart.withSampleData() { + return new RTLBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // Charts will determine if RTL is enabled by checking the directionality by + // requesting Directionality.of(context). This returns the text direction + // from the closest instance of that encloses the context passed to build + // the chart. A [TextDirection.rtl] will be treated as a RTL chart. This + // means that the directionality widget does not have to directly wrap each + // chart. It is show here as an example only. + // + // By default, when a chart detects RTL: + // Measure axis positions are flipped. Primary measure axis is on the right + // and the secondary measure axis is on the left (when used). + // Domain axis' first domain starts on the right and grows left. + // + // Optionally, [RTLSpec] can be passed in when creating the chart to specify + // chart display settings in RTL mode. + return new Directionality( + textDirection: TextDirection.rtl, + child: new charts.BarChart( + seriesList, + animate: animate, + vertical: false, + )); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/i18n/rtl_line_chart.dart b/charts_flutter/examples/lib/i18n/rtl_line_chart.dart new file mode 100644 index 000000000..8ae1035c1 --- /dev/null +++ b/charts_flutter/examples/lib/i18n/rtl_line_chart.dart @@ -0,0 +1,82 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// RTL Line chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class RTLLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + RTLLineChart(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and no transition. + factory RTLLineChart.withSampleData() { + return new RTLLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // Charts will determine if RTL is enabled by checking the directionality by + // requesting Directionality.of(context). This returns the text direction + // from the closest instance of that encloses the context passed to build + // the chart. A [TextDirection.rtl] will be treated as a RTL chart. This + // means that the directionality widget does not have to directly wrap each + // chart. It is show here as an example only. + // + // By default, when a chart detects RTL: + // Measure axis positions are flipped. Primary measure axis is on the right + // and the secondary measure axis is on the left (when used). + // Domain axis' first domain starts on the right and grows left. + return new Directionality( + textDirection: TextDirection.rtl, + child: new charts.LineChart( + seriesList, + animate: animate, + )); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/i18n/rtl_series_legend.dart b/charts_flutter/examples/lib/i18n/rtl_series_legend.dart new file mode 100644 index 000000000..04197a561 --- /dev/null +++ b/charts_flutter/examples/lib/i18n/rtl_series_legend.dart @@ -0,0 +1,134 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// RTL Bar chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class RTLSeriesLegend extends StatelessWidget { + final List seriesList; + final bool animate; + + RTLSeriesLegend(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory RTLSeriesLegend.withSampleData() { + return new RTLSeriesLegend( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // Charts will determine if RTL is enabled by checking the directionality by + // requesting Directionality.of(context). This returns the text direction + // from the closest instance of that encloses the context passed to build + // the chart. A [TextDirection.rtl] will be treated as a RTL chart. This + // means that the directionality widget does not have to directly wrap each + // chart. It is show here as an example only. + // + // When the legend behavior detects RTL: + // [BuildablePosition.start] is to the right of the chart. + // [BuildablePosition.end] is to the left of the chart. + // + // If the [BuildablePosition] is top or bottom, the start justification + // is to the right, and the end justification is to the left. + // + // The legend's tabular layout will also layout rows and columns from right + // to left. + // + // The below example changes the position to 'start' and max rows of 2 in + // order to show these effects, but are not required for SeriesLegend to + // work with the correct directionality. + return new Directionality( + textDirection: TextDirection.rtl, + child: new charts.BarChart( + seriesList, + animate: animate, + behaviors: [ + new charts.SeriesLegend( + position: charts.BuildablePosition.end, desiredMaxRows: 2) + ], + )); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', 20), + new OrdinalSales('2015', 35), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 10), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Other', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/image_test_only/legend.dart b/charts_flutter/examples/lib/image_test_only/legend.dart new file mode 100644 index 000000000..44d2b57d1 --- /dev/null +++ b/charts_flutter/examples/lib/image_test_only/legend.dart @@ -0,0 +1,118 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Legend example for image tests only. +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class ImageTestLegend extends StatelessWidget { + final List seriesList = _createSampleData(); + final charts.BuildablePosition position; + final TextDirection textDirection; + + ImageTestLegend(this.position, this.textDirection); + + factory ImageTestLegend.top(TextDirection textDirection) => + new ImageTestLegend(charts.BuildablePosition.top, textDirection); + + factory ImageTestLegend.bottom(TextDirection textDirection) => + new ImageTestLegend(charts.BuildablePosition.bottom, textDirection); + + factory ImageTestLegend.start(TextDirection textDirection) => + new ImageTestLegend(charts.BuildablePosition.start, textDirection); + + factory ImageTestLegend.end(TextDirection textDirection) => + new ImageTestLegend(charts.BuildablePosition.end, textDirection); + + @override + Widget build(BuildContext context) { + return new Directionality( + textDirection: textDirection, + child: new charts.BarChart( + seriesList, + animate: false, + barGroupingType: charts.BarGroupingType.grouped, + // Add the series legend behavior to the chart to turn on series legends. + // By default the legend will display above the chart. + behaviors: [new charts.SeriesLegend(position: position)], + )); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', 20), + new OrdinalSales('2015', 35), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 10), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Other', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/image_test_only/rtl_grouped.dart b/charts_flutter/examples/lib/image_test_only/rtl_grouped.dart new file mode 100644 index 000000000..580eaaebe --- /dev/null +++ b/charts_flutter/examples/lib/image_test_only/rtl_grouped.dart @@ -0,0 +1,97 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class RTLGroupedBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + RTLGroupedBarChart(this.seriesList, {this.animate}); + + factory RTLGroupedBarChart.withSampleData() { + return new RTLGroupedBarChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new Directionality( + textDirection: TextDirection.rtl, + child: new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + )); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/image_test_only/rtl_grouped_stacked.dart b/charts_flutter/examples/lib/image_test_only/rtl_grouped_stacked.dart new file mode 100644 index 000000000..0601eeecd --- /dev/null +++ b/charts_flutter/examples/lib/image_test_only/rtl_grouped_stacked.dart @@ -0,0 +1,142 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class RTLGroupedStackedBarChart extends StatelessWidget { + final List seriesList; + final bool animate; + + RTLGroupedStackedBarChart(this.seriesList, {this.animate}); + + factory RTLGroupedStackedBarChart.withSampleData() { + return new RTLGroupedStackedBarChart( + createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new Directionality( + textDirection: TextDirection.rtl, + child: new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.groupedStacked, + )); + } + + /// Create series list with multiple series + static List> createSampleData() { + final desktopSalesDataA = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesDataA = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesDataA = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final desktopSalesDataB = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tableSalesDataB = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesDataB = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + return [ + new charts.Series( + id: 'Desktop A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataA, + ), + new charts.Series( + id: 'Tablet A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataA, + ), + new charts.Series( + id: 'Mobile A', + seriesCategory: 'A', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataA, + ), + new charts.Series( + id: 'Desktop B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesDataB, + ), + new charts.Series( + id: 'Tablet B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesDataB, + ), + new charts.Series( + id: 'Mobile B', + seriesCategory: 'B', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesDataB, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/interactions/interactions_gallery.dart b/charts_flutter/examples/lib/interactions/interactions_gallery.dart new file mode 100644 index 000000000..22bc1c025 --- /dev/null +++ b/charts_flutter/examples/lib/interactions/interactions_gallery.dart @@ -0,0 +1,141 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:meta/meta.dart'; +import '../gallery_scaffold.dart'; +import 'selection_bar_highlight.dart'; +import 'selection_line_highlight.dart'; +import 'selection_callback_example.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'Selection Bar Highlight', + subtitle: 'Simple bar chart with tap activation', + childBuilder: (List series) => + new SelectionBarHighlight(series), + seriesListBuilder: _createSingleOrdinalSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'Selection Line Highlight', + subtitle: 'Line chart with tap and drag activation', + childBuilder: (List series) => + new SelectionLineHighlight(series), + seriesListBuilder: _createSingleLinearSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.flag), + title: 'Selection Callback Example', + subtitle: 'Timeseries that updates external components on selection', + childBuilder: (List series) => + new SelectionCallbackExample(series), + seriesListBuilder: _timeSeriesFactory(count: 2), + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createSingleOrdinalSeries() { + final random = new Random(); + + final data = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createSingleLinearSeries() { + final random = new Random(); + + final data = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +SeriesListBuilder _timeSeriesFactory({@required int count}) { + return () { + final seriesList = >[]; + final seriesNames = [ + 'US Sales', + 'UK Sales', + 'MX Sales', + 'JP Sales' + ]; + final random = new Random(); + + for (int i = 0; i < count; i++) { + final data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 9, 26), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 10, 3), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 10, 10), random.nextInt(100)), + ]; + + seriesList.add(new charts.Series( + id: seriesNames[i], + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: data, + )); + } + + return seriesList; + }; +} diff --git a/charts_flutter/examples/lib/interactions/selection_bar_highlight.dart b/charts_flutter/examples/lib/interactions/selection_bar_highlight.dart new file mode 100644 index 000000000..587a51fa4 --- /dev/null +++ b/charts_flutter/examples/lib/interactions/selection_bar_highlight.dart @@ -0,0 +1,78 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SelectionBarHighlight extends StatelessWidget { + final List seriesList; + final bool animate; + + SelectionBarHighlight(this.seriesList, {this.animate}); + + /// Creates a [BarChart] with sample data and no transition. + factory SelectionBarHighlight.withSampleData() { + return new SelectionBarHighlight( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // This is just a simple bar chart with optional property + // [defaultInteractions] set to true to include the default + // interactions/behaviors when building the chart. + // This includes bar highlighting. + // + // Note: defaultInteractions defaults to true. + // + // [defaultInteractions] can be set to false to avoid the default + // interactions. + return new charts.BarChart( + seriesList, + animate: animate, + defaultInteractions: true, + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/interactions/selection_callback_example.dart b/charts_flutter/examples/lib/interactions/selection_callback_example.dart new file mode 100644 index 000000000..27827b83e --- /dev/null +++ b/charts_flutter/examples/lib/interactions/selection_callback_example.dart @@ -0,0 +1,156 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Timeseries chart with example of updating external state based on selection. +/// +/// A SelectionModelConfig can be provided for each of the different +/// [SelectionModel] (currently info and action). +/// +/// [SelectionModelType.info] is the default selection chart exploration type +/// initiated by some tap event. This is a different model from +/// [SelectionModelType.action] which is typically used to select some value as +/// an input to some other UI component. This allows dual state of exploring +/// and selecting data via different touch events. +/// +/// See [SelectNearest] behavior on setting the different ways of triggering +/// [SelectionModel] updates from hover & click events. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SelectionCallbackExample extends StatefulWidget { + final List seriesList; + final bool animate; + + SelectionCallbackExample(this.seriesList, {this.animate}); + + /// Creates a [charts.TimeSeriesChart] with sample data and no transition. + factory SelectionCallbackExample.withSampleData() { + return new SelectionCallbackExample( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + // We need a Stateful widget to build the selection details with the current + // selection as the state. + @override + State createState() => new _SelectionCallbackState(); + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final us_data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), 5), + new TimeSeriesSales(new DateTime(2017, 9, 26), 25), + new TimeSeriesSales(new DateTime(2017, 10, 3), 78), + new TimeSeriesSales(new DateTime(2017, 10, 10), 54), + ]; + + final uk_data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), 15), + new TimeSeriesSales(new DateTime(2017, 9, 26), 33), + new TimeSeriesSales(new DateTime(2017, 10, 3), 68), + new TimeSeriesSales(new DateTime(2017, 10, 10), 48), + ]; + + return [ + new charts.Series( + id: 'US Sales', + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: us_data, + ), + new charts.Series( + id: 'UK Sales', + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: uk_data, + ) + ]; + } +} + +class _SelectionCallbackState extends State { + DateTime _time; + Map _measures; + + // Listens to the underlying selection changes, and updates the information + // relevant to building the primitive legend like information under the + // chart. + _onSelectionChanged(charts.SelectionModel model) { + final selectedDatum = model.selectedDatum; + + DateTime time; + final measures = {}; + + // We get the model that updated with a list of [SeriesDatum] which is + // simply a pair of series & datum. + // + // Walk the selection updating the measures map, storing off the sales and + // series name for each selection point. + if (selectedDatum.isNotEmpty) { + time = selectedDatum.first.datum.time; + selectedDatum + .forEach((charts.SeriesDatum datumPair) { + measures[datumPair.series.displayName] = datumPair.datum.sales; + }); + } + + // Request a build. + setState(() { + _time = time; + _measures = measures; + }); + } + + @override + Widget build(BuildContext context) { + // The children consist of a Chart and Text widgets below to hold the info. + final children = [ + new SizedBox( + height: 150.0, + child: new charts.TimeSeriesChart( + widget.seriesList, + animate: widget.animate, + selectionModels: [ + new charts.SelectionModelConfig( + type: charts.SelectionModelType.info, + listener: _onSelectionChanged, + ) + ], + )), + ]; + + // If there is a selection, then include the details. + if (_time != null) { + children.add(new Padding( + padding: new EdgeInsets.only(top: 5.0), + child: new Text(_time.toString()))); + } + _measures?.forEach((String series, num value) { + children.add(new Text('${series}: ${value}')); + }); + + return new Column(children: children); + } +} + +/// Sample time series data type. +class TimeSeriesSales { + final DateTime time; + final int sales; + + TimeSeriesSales(this.time, this.sales); +} diff --git a/charts_flutter/examples/lib/interactions/selection_line_highlight.dart b/charts_flutter/examples/lib/interactions/selection_line_highlight.dart new file mode 100644 index 000000000..9d6b71fa8 --- /dev/null +++ b/charts_flutter/examples/lib/interactions/selection_line_highlight.dart @@ -0,0 +1,88 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SelectionLineHighlight extends StatelessWidget { + final List seriesList; + final bool animate; + + SelectionLineHighlight(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and no transition. + factory SelectionLineHighlight.withSampleData() { + return new SelectionLineHighlight( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + // This is just a simple line chart with a behavior that highlights the + // selected points along the lines. A point will be drawn at the selected + // datum's x,y coordinate, and a vertical follow line will be drawn through + // it. + // + // A [Charts.LinePointHighlighter] behavior is added manually to enable the + // highlighting effect. + // + // As an alternative, [defaultInteractions] can be set to true to include + // the default chart interactions, including a LinePointHighlighter. + return new charts.LineChart(seriesList, animate: animate, behaviors: [ + // Optional - Configures a [LinePointHighlighter] behavior with a + // vertical follow line. A vertical follow line is included by default, + // but is shown here as an example configuration. + new charts.LinePointHighlighter( + showHorizontalFollowLine: false, showVerticalFollowLine: true), + // Optional - By default, select nearest is configured to trigger with tap + // so that a user can have pan/zoom behavior and line point highlighter. + // Changing the trigger to tap and drag allows the highlighter to follow + // the dragging gesture but it is not recommended to be used when pan/zoom + // behavior is enabled. + new charts.SelectNearest( + eventTrigger: charts.SelectNearestTrigger.tapAndDrag) + ]); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/legends/legend_custom_symbol.dart b/charts_flutter/examples/lib/legends/legend_custom_symbol.dart new file mode 100644 index 000000000..f20328ca0 --- /dev/null +++ b/charts_flutter/examples/lib/legends/legend_custom_symbol.dart @@ -0,0 +1,132 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart with custom symbol in legend example. +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +/// Example custom renderer that renders [IconData]. +/// +/// This is used to show that legend symbols can be assigned a custom symbol. +class IconRenderer extends charts.SymbolRenderer { + final IconData iconData; + + IconRenderer(this.iconData); + + @override + Widget build(BuildContext context, + {Size size, Color color, bool isSelected}) { + return new SizedBox.fromSize( + size: size, child: new Icon(iconData, color: color, size: 12.0)); + } +} + +class LegendWithCustomSymbol extends StatelessWidget { + final List seriesList; + final bool animate; + + LegendWithCustomSymbol(this.seriesList, {this.animate}); + + factory LegendWithCustomSymbol.withSampleData() { + return new LegendWithCustomSymbol( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + // Add the legend behavior to the chart to turn on legends. + // By default the legend will display above the chart. + // + // To change the symbol used in the legend, set the renderer attribute of + // symbolRendererKey to a SymbolRenderer. + behaviors: [new charts.SeriesLegend()], + defaultRenderer: new charts.BarRendererConfig( + symbolRenderer: new IconRenderer(Icons.cloud)), + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', 20), + new OrdinalSales('2015', 35), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 10), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Other', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData, + ) + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/legends/legend_options.dart b/charts_flutter/examples/lib/legends/legend_options.dart new file mode 100644 index 000000000..0675b531d --- /dev/null +++ b/charts_flutter/examples/lib/legends/legend_options.dart @@ -0,0 +1,138 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart with example of a legend with customized position, justification, +/// desired max rows, and padding. These options are shown as an example of how +/// to use the customizations, they do not necessary have to be used together in +/// this way. Choosing [end] as the position does not require the justification +/// to also be [endDrawArea]. +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class LegendOptions extends StatelessWidget { + final List seriesList; + final bool animate; + + LegendOptions(this.seriesList, {this.animate}); + + factory LegendOptions.withSampleData() { + return new LegendOptions( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + // Add the legend behavior to the chart to turn on legends. + // This example shows how to change the position and justification of + // the legend, in addition to altering the max rows and padding. + behaviors: [ + new charts.SeriesLegend( + // Positions for "start" and "end" will be left and right respectively + // for widgets with a build context that has directionality ltr. + // For rtl, "start" and "end" will be right and left respectively. + // Since this example has directionality of ltr, the legend is + // positioned on the right side of the chart. + position: charts.BuildablePosition.end, + // For a legend that is positioned on the left or right of the chart, + // setting the justification for [endDrawArea] is aligned to the + // bottom of the chart draw area. + outsideJustification: charts.OutsideJustification.endDrawArea, + // By default, if the position of the chart is on the left or right of + // the chart, [horizontalFirst] is set to false. This means that the + // legend entries will grow as new rows first instead of a new column. + horizontalFirst: false, + // By setting this value to 2, the legend entries will grow up to two + // rows before adding a new column. + desiredMaxRows: 2, + // This defines the padding around each legend entry. + cellPadding: new EdgeInsets.only(right: 4.0, bottom: 4.0), + ) + ], + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', 20), + new OrdinalSales('2015', 35), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 10), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Other', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/legends/legends_gallery.dart b/charts_flutter/examples/lib/legends/legends_gallery.dart new file mode 100644 index 000000000..41c9a3fe6 --- /dev/null +++ b/charts_flutter/examples/lib/legends/legends_gallery.dart @@ -0,0 +1,116 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; +import '../gallery_scaffold.dart'; +import 'simple_series_legend.dart'; +import 'legend_options.dart'; +import 'legend_custom_symbol.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Series Legend', + subtitle: 'A series legend for a bar chart with default settings', + childBuilder: (List series) => + new SimpleSeriesLegend(series), + seriesListBuilder: _createMultipleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Series Legend Options', + subtitle: + 'A series legend with custom positioning and spacing for a bar chart', + childBuilder: (List series) => new LegendOptions(series), + seriesListBuilder: _createMultipleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.insert_chart), + title: 'Series Legend Custom Symbol', + subtitle: 'A series legend using a custom symbol renderer', + childBuilder: (List series) => + new LegendWithCustomSymbol(series), + seriesListBuilder: _createMultipleSeries, + ), + ]; +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} + +/// Create series list with multiple series. +List> _createMultipleSeries( + {String suffix = ''}) { + final random = new Random(); + + final desktopSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final tableSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', random.nextInt(100)), + new OrdinalSales('2015', random.nextInt(100)), + new OrdinalSales('2016', random.nextInt(100)), + new OrdinalSales('2017', random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData), + new charts.Series( + id: 'Tablet${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tableSalesData), + new charts.Series( + id: 'Mobile${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData), + new charts.Series( + id: 'Other${suffix}', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData), + ]; +} diff --git a/charts_flutter/examples/lib/legends/simple_series_legend.dart b/charts_flutter/examples/lib/legends/simple_series_legend.dart new file mode 100644 index 000000000..d98ab2d1a --- /dev/null +++ b/charts_flutter/examples/lib/legends/simple_series_legend.dart @@ -0,0 +1,111 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Bar chart with series legend example +import 'package:flutter/material.dart'; +import 'package:charts_flutter/flutter.dart' as charts; + +class SimpleSeriesLegend extends StatelessWidget { + final List seriesList; + final bool animate; + + SimpleSeriesLegend(this.seriesList, {this.animate}); + + factory SimpleSeriesLegend.withSampleData() { + return new SimpleSeriesLegend( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.BarChart( + seriesList, + animate: animate, + barGroupingType: charts.BarGroupingType.grouped, + // Add the series legend behavior to the chart to turn on series legends. + // By default the legend will display above the chart. + behaviors: [new charts.SeriesLegend()], + ); + } + + /// Create series list with multiple series + static List> _createSampleData() { + final desktopSalesData = [ + new OrdinalSales('2014', 5), + new OrdinalSales('2015', 25), + new OrdinalSales('2016', 100), + new OrdinalSales('2017', 75), + ]; + + final tabletSalesData = [ + new OrdinalSales('2014', 25), + new OrdinalSales('2015', 50), + new OrdinalSales('2016', 10), + new OrdinalSales('2017', 20), + ]; + + final mobileSalesData = [ + new OrdinalSales('2014', 10), + new OrdinalSales('2015', 15), + new OrdinalSales('2016', 50), + new OrdinalSales('2017', 45), + ]; + + final otherSalesData = [ + new OrdinalSales('2014', 20), + new OrdinalSales('2015', 35), + new OrdinalSales('2016', 15), + new OrdinalSales('2017', 10), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: desktopSalesData, + ), + new charts.Series( + id: 'Tablet', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: tabletSalesData, + ), + new charts.Series( + id: 'Mobile', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: mobileSalesData, + ), + new charts.Series( + id: 'Other', + domainFn: (OrdinalSales sales, _) => sales.year, + measureFn: (OrdinalSales sales, _) => sales.sales, + data: otherSalesData, + ), + ]; + } +} + +/// Sample ordinal data type. +class OrdinalSales { + final String year; + final int sales; + + OrdinalSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/line_chart/animation_zoom.dart b/charts_flutter/examples/lib/line_chart/animation_zoom.dart new file mode 100644 index 000000000..ebc4f9d2e --- /dev/null +++ b/charts_flutter/examples/lib/line_chart/animation_zoom.dart @@ -0,0 +1,69 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Example of a line chart with pan and zoom enabled via +/// [Charts.PanAndZoomBehavior]. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class LineAnimationZoomChart extends StatelessWidget { + final List seriesList; + final bool animate; + + LineAnimationZoomChart(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and no transition. + factory LineAnimationZoomChart.withSampleData() { + return new LineAnimationZoomChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.LineChart(seriesList, animate: animate, behaviors: [ + new charts.PanAndZoomBehavior(), + ]); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/line_chart/dash_pattern.dart b/charts_flutter/examples/lib/line_chart/dash_pattern.dart new file mode 100644 index 000000000..f5457a26e --- /dev/null +++ b/charts_flutter/examples/lib/line_chart/dash_pattern.dart @@ -0,0 +1,98 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Dash pattern line chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +/// Example of a line chart rendered with dash patterns. +class DashPatternLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + DashPatternLineChart(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and no transition. + factory DashPatternLineChart.withSampleData() { + return new DashPatternLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.LineChart(seriesList, animate: animate); + } + + /// Create three series with sample hard coded data. + static List> _createSampleData() { + final myFakeDesktopData = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + var myFakeTabletData = [ + new LinearSales(0, 10), + new LinearSales(1, 50), + new LinearSales(2, 200), + new LinearSales(3, 150), + ]; + + var myFakeMobileData = [ + new LinearSales(0, 15), + new LinearSales(1, 75), + new LinearSales(2, 300), + new LinearSales(3, 225), + ]; + + return [ + new charts.Series( + id: 'Desktop', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: myFakeDesktopData, + ), + new charts.Series( + id: 'Tablet', + colorFn: (_, __) => charts.MaterialPalette.red.shadeDefault, + dashPattern: [2, 2], + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: myFakeTabletData, + ), + new charts.Series( + id: 'Mobile', + colorFn: (_, __) => charts.MaterialPalette.green.shadeDefault, + dashPattern: [8, 3, 2, 3], + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: myFakeMobileData, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/line_chart/line_gallery.dart b/charts_flutter/examples/lib/line_chart/line_gallery.dart new file mode 100644 index 000000000..aa77683a4 --- /dev/null +++ b/charts_flutter/examples/lib/line_chart/line_gallery.dart @@ -0,0 +1,138 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import '../gallery_scaffold.dart'; +import 'animation_zoom.dart'; +import 'dash_pattern.dart'; +import 'range_annotation.dart'; +import 'simple.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Simple Line Chart', + subtitle: 'With a single series and default line point highlighter', + childBuilder: (List series) => new SimpleLineChart(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Dash Pattern Line Chart', + subtitle: 'With three series and default line point highlighter', + childBuilder: (List series) => + new DashPatternLineChart(series), + seriesListBuilder: _createMultipleSeriesWithDashPattern, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Range Annotation Line Chart', + subtitle: 'Line chart with range annotations', + childBuilder: (List series) => + new LineRangeAnnotationChart(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Pan and Zoom Line Chart', + subtitle: 'Simple line chart pan and zoom behaviors enabled', + childBuilder: (List series) => + new LineAnimationZoomChart(series), + seriesListBuilder: _createSingleSeries, + ), + ]; +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} + +/// Create one series with random data. +List> _createSingleSeries() { + final random = new Random(); + + final data = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; +} + +/// Create multiple series with a forward hatch pattern on the middle series. +List> _createMultipleSeriesWithDashPattern( + {String rendererId}) { + final random = new Random(); + + final desktopSalesData = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + final tableSalesData = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + final mobileSalesData = [ + new LinearSales(0, random.nextInt(100)), + new LinearSales(1, random.nextInt(100)), + new LinearSales(2, random.nextInt(100)), + new LinearSales(3, random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Desktop', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: desktopSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Tablet', + dashPattern: [2, 2], + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: tableSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + new charts.Series( + id: 'Mobile', + dashPattern: [8, 3, 2, 3], + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: mobileSalesData) + ..setAttribute(charts.rendererIdKey, rendererId), + ]; +} diff --git a/charts_flutter/examples/lib/line_chart/range_annotation.dart b/charts_flutter/examples/lib/line_chart/range_annotation.dart new file mode 100644 index 000000000..18a06d528 --- /dev/null +++ b/charts_flutter/examples/lib/line_chart/range_annotation.dart @@ -0,0 +1,78 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Line chart with range annotation example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class LineRangeAnnotationChart extends StatelessWidget { + final List seriesList; + final bool animate; + + LineRangeAnnotationChart(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and range annotations. + /// + /// The second series extends beyond the range of the series data, + /// demonstrating the effect of the [Charts.RangeAnnotation.extendAxis] flag. + /// This can be set to false to disable range extension. + factory LineRangeAnnotationChart.withSampleData() { + return new LineRangeAnnotationChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.LineChart(seriesList, animate: animate, behaviors: [ + new charts.RangeAnnotation([ + new charts.RangeAnnotationSegment( + 0.5, 1.0, charts.RangeAnnotationAxisType.domain), + new charts.RangeAnnotationSegment( + 2, 4, charts.RangeAnnotationAxisType.domain, + color: charts.MaterialPalette.gray.shade200), + ]), + ]); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/line_chart/simple.dart b/charts_flutter/examples/lib/line_chart/simple.dart new file mode 100644 index 000000000..6be532b98 --- /dev/null +++ b/charts_flutter/examples/lib/line_chart/simple.dart @@ -0,0 +1,67 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Line chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SimpleLineChart extends StatelessWidget { + final List seriesList; + final bool animate; + + SimpleLineChart(this.seriesList, {this.animate}); + + /// Creates a [LineChart] with sample data and no transition. + factory SimpleLineChart.withSampleData() { + return new SimpleLineChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.LineChart(seriesList, animate: animate); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new LinearSales(0, 5), + new LinearSales(1, 25), + new LinearSales(2, 100), + new LinearSales(3, 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (LinearSales sales, _) => sales.year, + measureFn: (LinearSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample linear data type. +class LinearSales { + final int year; + final int sales; + + LinearSales(this.year, this.sales); +} diff --git a/charts_flutter/examples/lib/main.dart b/charts_flutter/examples/lib/main.dart new file mode 100644 index 000000000..add19300d --- /dev/null +++ b/charts_flutter/examples/lib/main.dart @@ -0,0 +1,54 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'app_config.dart'; +import 'home.dart'; + +/// The main gallery app widget. +class GalleryApp extends StatefulWidget { + GalleryApp({Key key}) : super(key: key); + + @override + GalleryAppState createState() => new GalleryAppState(); +} + +/// The main gallery app state. +/// +/// Controls performance overlay, and instantiates a [Home] widget. +class GalleryAppState extends State { + // Initialize app settings from the default configuration. + bool _showPerformanceOverlay = defaultConfig.showPerformanceOverlay; + + @override + Widget build(BuildContext context) { + return new MaterialApp( + title: defaultConfig.appName, + theme: defaultConfig.theme, + showPerformanceOverlay: _showPerformanceOverlay, + home: new Home( + showPerformanceOverlay: _showPerformanceOverlay, + onShowPerformanceOverlayChanged: (bool value) { + setState(() { + _showPerformanceOverlay = value; + }); + }, + )); + } +} + +void main() { + runApp(new GalleryApp()); +} diff --git a/charts_flutter/examples/lib/time_series_chart/range_annotation.dart b/charts_flutter/examples/lib/time_series_chart/range_annotation.dart new file mode 100644 index 000000000..7ebdec092 --- /dev/null +++ b/charts_flutter/examples/lib/time_series_chart/range_annotation.dart @@ -0,0 +1,78 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Time series chart with range annotation example +/// +/// The example future range annotation extends beyond the range of the series +/// data, demonstrating the effect of the [Charts.RangeAnnotation.extendAxis] +/// flag. This can be set to false to disable range extension. +/// +/// Additional annotations may be added simply by adding additional +/// [Charts.RangeAnnotationSegment] items to the list. +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class TimeSeriesRangeAnnotationChart extends StatelessWidget { + final List seriesList; + final bool animate; + + TimeSeriesRangeAnnotationChart(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory TimeSeriesRangeAnnotationChart.withSampleData() { + return new TimeSeriesRangeAnnotationChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart(seriesList, animate: animate, behaviors: [ + new charts.RangeAnnotation([ + new charts.RangeAnnotationSegment(new DateTime(2017, 10, 4), + new DateTime(2017, 10, 15), charts.RangeAnnotationAxisType.domain), + ]), + ]); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), 5), + new TimeSeriesSales(new DateTime(2017, 9, 26), 25), + new TimeSeriesSales(new DateTime(2017, 10, 3), 100), + new TimeSeriesSales(new DateTime(2017, 10, 10), 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class TimeSeriesSales { + final DateTime time; + final int sales; + + TimeSeriesSales(this.time, this.sales); +} diff --git a/charts_flutter/examples/lib/time_series_chart/simple.dart b/charts_flutter/examples/lib/time_series_chart/simple.dart new file mode 100644 index 000000000..dabec8560 --- /dev/null +++ b/charts_flutter/examples/lib/time_series_chart/simple.dart @@ -0,0 +1,74 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Timeseries chart example +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; + +class SimpleTimeSeriesChart extends StatelessWidget { + final List seriesList; + final bool animate; + + SimpleTimeSeriesChart(this.seriesList, {this.animate}); + + /// Creates a [TimeSeriesChart] with sample data and no transition. + factory SimpleTimeSeriesChart.withSampleData() { + return new SimpleTimeSeriesChart( + _createSampleData(), + // Disable animations for image tests. + animate: false, + ); + } + + @override + Widget build(BuildContext context) { + return new charts.TimeSeriesChart( + seriesList, + animate: animate, + // Optionally pass in a [DateTimeFactory] used by the chart. The factory + // should create the same type of [DateTime] as the data provided. If none + // specified, the default creates local date time. + dateTimeFactory: const charts.LocalDateTimeFactory(), + ); + } + + /// Create one series with sample hard coded data. + static List> _createSampleData() { + final data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), 5), + new TimeSeriesSales(new DateTime(2017, 9, 26), 25), + new TimeSeriesSales(new DateTime(2017, 10, 3), 100), + new TimeSeriesSales(new DateTime(2017, 10, 10), 75), + ]; + + return [ + new charts.Series( + id: 'Sales', + colorFn: (_, __) => charts.MaterialPalette.blue.shadeDefault, + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: data, + ) + ]; + } +} + +/// Sample time series data type. +class TimeSeriesSales { + final DateTime time; + final int sales; + + TimeSeriesSales(this.time, this.sales); +} diff --git a/charts_flutter/examples/lib/time_series_chart/time_series_gallery.dart b/charts_flutter/examples/lib/time_series_chart/time_series_gallery.dart new file mode 100644 index 000000000..a86a9c155 --- /dev/null +++ b/charts_flutter/examples/lib/time_series_chart/time_series_gallery.dart @@ -0,0 +1,71 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Random; +import 'package:charts_flutter/flutter.dart' as charts; +import 'package:flutter/material.dart'; +import '../gallery_scaffold.dart'; +import 'range_annotation.dart'; +import 'simple.dart'; + +List buildGallery() { + return [ + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Time Series Chart', + subtitle: 'Simple single time series chart', + childBuilder: (List series) => + new SimpleTimeSeriesChart(series), + seriesListBuilder: _createSingleSeries, + ), + new GalleryScaffold( + listTileIcon: new Icon(Icons.show_chart), + title: 'Range Annotation Time Series Chart', + subtitle: 'Time series chart with future range annotation', + childBuilder: (List series) => + new TimeSeriesRangeAnnotationChart(series), + seriesListBuilder: _createSingleSeries, + ), + ]; +} + +/// Sample time series data type. +class TimeSeriesSales { + final DateTime time; + final int sales; + + TimeSeriesSales(this.time, this.sales); +} + +/// Create one series with random data. +List> _createSingleSeries() { + final random = new Random(); + + final data = [ + new TimeSeriesSales(new DateTime(2017, 9, 19), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 9, 26), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 10, 3), random.nextInt(100)), + new TimeSeriesSales(new DateTime(2017, 10, 10), random.nextInt(100)), + ]; + + return [ + new charts.Series( + id: 'Sales', + domainFn: (TimeSeriesSales sales, _) => sales.time, + measureFn: (TimeSeriesSales sales, _) => sales.sales, + data: data, + ) + ]; +} diff --git a/charts_flutter/examples/pubspec.yaml b/charts_flutter/examples/pubspec.yaml new file mode 100644 index 000000000..f898adf2d --- /dev/null +++ b/charts_flutter/examples/pubspec.yaml @@ -0,0 +1,14 @@ +name: example +description: Charts-Flutter Demo +dependencies: + charts_flutter: + path: ../ + cupertino_icons: ^0.1.0 + flutter: + sdk: flutter + flutter_test: + sdk: flutter + meta: ^1.1.1 + intl: ^0.15.2 +flutter: + uses-material-design: true diff --git a/charts_flutter/lib/flutter.dart b/charts_flutter/lib/flutter.dart new file mode 100644 index 000000000..405cda986 --- /dev/null +++ b/charts_flutter/lib/flutter.dart @@ -0,0 +1,121 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export 'package:charts_common/src/chart/common/base_chart.dart' show BaseChart; +export 'package:charts_common/src/chart/layout/layout_view.dart' + show LayoutPosition, ViewMargin; +export 'package:charts_common/src/chart/layout/layout_config.dart' + show MarginSpec; +export 'package:charts_common/src/chart/common/chart_canvas.dart' + show ChartCanvas, FillPatternType; +export 'package:charts_common/src/chart/common/processed_series.dart' + show SeriesDatum, ImmutableSeries; +export 'package:charts_common/src/chart/common/selection_model/selection_model.dart' + show SelectionModelType, SelectionModel, SelectionModelListener; +export 'package:charts_common/src/chart/common/chart_context.dart' + show ChartContext; +export 'package:charts_common/src/chart/common/behavior/chart_behavior.dart' + show ChartBehavior; +export 'package:charts_common/src/chart/common/behavior/range_annotation.dart' + show RangeAnnotationAxisType, RangeAnnotationSegment; +export 'package:charts_common/src/chart/common/behavior/select_nearest.dart' + show SelectNearestTrigger; +export 'package:charts_common/src/chart/common/behavior/a11y/a11y_explore_behavior.dart' + show ExploreModeTrigger; +export 'package:charts_common/src/chart/common/behavior/a11y/domain_a11y_explore_behavior.dart' + show VocalizationCallback; +export 'package:charts_common/src/common/color.dart' show Color; +export 'package:charts_common/src/common/gesture_listener.dart' + show GestureListener; +export 'package:charts_common/src/common/material_palette.dart' + show MaterialPalette; +export 'package:charts_common/src/common/performance.dart' show Performance; +export 'package:charts_common/src/common/date_time_factory.dart' + show DateTimeFactory, LocalDateTimeFactory, UTCDateTimeFactory; +export 'package:charts_common/src/common/rtl_spec.dart' + show RTLSpec, AxisPosition; +export 'package:charts_common/src/common/quantum_palette.dart' + show QuantumPalette; +export 'package:charts_common/src/common/style/style_factory.dart' + show StyleFactory; +export 'package:charts_common/src/common/style/material_style.dart' + show MaterialStyle; +export 'package:charts_common/src/common/style/quantum_style.dart' + show QuantumStyle; +export 'package:charts_common/src/data/series.dart' show Series; +export 'package:charts_common/src/chart/bar/bar_renderer_config.dart' + show BarRendererConfig; +export 'package:charts_common/src/chart/bar/bar_label_decorator.dart' + show BarLabelDecorator, BarLabelAnchor, BarLabelPosition; +export 'package:charts_common/src/chart/bar/bar_target_line_renderer_config.dart' + show BarTargetLineRendererConfig; +export 'package:charts_common/src/chart/bar/base_bar_renderer_config.dart' + show BarGroupingType; +export 'package:charts_common/src/chart/cartesian/cartesian_chart.dart' + show CartesianChart; +export 'package:charts_common/src/chart/cartesian/axis/axis.dart' + show measureAxisIdKey; +export 'package:charts_common/src/chart/cartesian/axis/spec/axis_spec.dart' + show TickLabelAnchor, TickLabelJustification, TextStyleSpec, LineStyleSpec; +export 'package:charts_common/src/chart/cartesian/axis/spec/numeric_axis_spec.dart' + show + NumericAxisSpec, + BasicNumericTickFormatterSpec, + BasicNumericTickProviderSpec, + StaticNumericTickProviderSpec; +export 'package:charts_common/src/chart/cartesian/axis/spec/ordinal_axis_spec.dart' + show + OrdinalAxisSpec, + OrdinalTickFormatterSpec, + OrdinalTickProviderSpec, + StaticOrdinalTickProviderSpec; +export 'package:charts_common/src/chart/cartesian/axis/spec/date_time_axis_spec.dart' + show + DateTimeAxisSpec, + AutoDateTimeTickFormatterSpec, + AutoDateTimeTickProviderSpec, + TimeFormatterSpec, + StaticDateTimeTickProviderSpec; +export 'package:charts_common/src/chart/cartesian/axis/spec/tick_spec.dart' + show TickSpec; +export 'package:charts_common/src/chart/cartesian/axis/draw_strategy/small_tick_draw_strategy.dart' + show SmallTickRendererSpec; +export 'package:charts_common/src/chart/cartesian/axis/draw_strategy/gridline_draw_strategy.dart' + show GridlineRendererSpec; +export 'package:charts_common/src/chart/cartesian/axis/draw_strategy/none_draw_strategy.dart' + show NoneRenderSpec; +export 'package:charts_common/src/chart/line/line_renderer_config.dart' + show LineRendererConfig; +export 'package:charts_common/src/chart/common/series_renderer.dart' + show rendererIdKey; + +export 'src/bar_chart.dart'; +export 'src/base_chart.dart' show LayoutConfig; +export 'src/line_chart.dart'; +export 'src/time_series_chart.dart'; +export 'src/selection_model_config.dart' show SelectionModelConfig; +export 'src/behaviors/chart_behavior.dart' + show BuildablePosition, OutsideJustification, InsideJustification; +export 'src/behaviors/domain_highlighter.dart' show DomainHighlighter; +export 'src/behaviors/line_point_highlighter.dart' show LinePointHighlighter; +export 'src/behaviors/range_annotation.dart' show RangeAnnotation; +export 'src/behaviors/select_nearest.dart' show SelectNearest; +export 'src/behaviors/a11y/domain_a11y_explore_behavior.dart' + show DomainA11yExploreBehavior; +export 'src/behaviors/legend/series_legend.dart' show SeriesLegend; +export 'src/behaviors/zoom/pan_and_zoom_behavior.dart' show PanAndZoomBehavior; +export 'src/behaviors/zoom/pan_behavior.dart' show PanBehavior; +export 'src/symbol_renderer.dart' + show SymbolRenderer, RoundedRectSymbolRenderer; diff --git a/charts_flutter/lib/src/bar_chart.dart b/charts_flutter/lib/src/bar_chart.dart new file mode 100644 index 000000000..b0077f1d7 --- /dev/null +++ b/charts_flutter/lib/src/bar_chart.dart @@ -0,0 +1,89 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show + AxisSpec, + BarChart, + BarGroupingType, + BarRendererConfig, + BarRendererDecorator, + BaseChart, + RTLSpec, + Series, + SeriesRendererConfig; +import 'behaviors/domain_highlighter.dart' show DomainHighlighter; +import 'behaviors/chart_behavior.dart' show ChartBehavior; +import 'package:meta/meta.dart' show immutable; +import 'base_chart.dart' show LayoutConfig; +import 'base_chart_state.dart' show BaseChartState; +import 'cartesian_chart.dart' show CartesianChart; +import 'selection_model_config.dart' show SelectionModelConfig; +import 'symbol_renderer.dart' show RoundedRectSymbolRenderer; + +@immutable +class BarChart extends CartesianChart { + final bool vertical; + final common.BarRendererDecorator barRendererDecorator; + + BarChart( + List seriesList, { + bool animate, + Duration animationDuration, + common.AxisSpec domainAxis, + common.AxisSpec primaryMeasureAxis, + common.AxisSpec secondaryMeasureAxis, + common.BarGroupingType barGroupingType, + common.BarRendererConfig defaultRenderer, + List customSeriesRenderers, + List behaviors, + List selectionModels, + common.RTLSpec rtlSpec, + this.vertical: true, + bool defaultInteractions: true, + LayoutConfig layoutConfig, + this.barRendererDecorator, + }) : super( + seriesList, + animate: animate, + animationDuration: animationDuration, + domainAxis: domainAxis, + primaryMeasureAxis: primaryMeasureAxis, + secondaryMeasureAxis: secondaryMeasureAxis, + defaultRenderer: defaultRenderer ?? + new common.BarRendererConfig( + groupingType: barGroupingType, + barRendererDecorator: barRendererDecorator, + symbolRenderer: new RoundedRectSymbolRenderer()), + customSeriesRenderers: customSeriesRenderers, + behaviors: behaviors, + selectionModels: selectionModels, + rtlSpec: rtlSpec, + defaultInteractions: defaultInteractions, + layoutConfig: layoutConfig, + ); + + @override + common.BaseChart createCommonChart(BaseChartState chartState) => + new common.BarChart( + vertical: vertical, layoutConfig: layoutConfig?.commonLayoutConfig); + + @override + void addDefaultInteractions(List behaviors) { + super.addDefaultInteractions(behaviors); + + behaviors.add(new DomainHighlighter()); + } +} diff --git a/charts_flutter/lib/src/base_chart.dart b/charts_flutter/lib/src/base_chart.dart new file mode 100644 index 000000000..d940c147a --- /dev/null +++ b/charts_flutter/lib/src/base_chart.dart @@ -0,0 +1,255 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show + BaseChart, + LayoutConfig, + MarginSpec, + Performance, + RTLSpec, + Series, + SeriesRendererConfig, + SelectionModelType, + SelectNearestTrigger; +import 'behaviors/select_nearest.dart' show SelectNearest; +import 'package:meta/meta.dart' show immutable, required; +import 'behaviors/chart_behavior.dart' + show ChartBehavior, ChartStateBehavior, GestureType; +import 'selection_model_config.dart' show SelectionModelConfig; +import 'package:flutter/material.dart' show StatefulWidget; +import 'base_chart_state.dart' show BaseChartState; + +@immutable +abstract class BaseChart extends StatefulWidget { + /// Series list to draw. + final List seriesList; + + /// Animation transitions. + final bool animate; + final Duration animationDuration; + + /// Used to configure the margin sizes around the drawArea that the axis and + /// other things render into. + final LayoutConfig layoutConfig; + + // Default renderer used to draw series data on the chart. + final common.SeriesRendererConfig defaultRenderer; + + /// Include the default interactions or not. + final bool defaultInteractions; + + final List behaviors; + + final List selectionModels; + + // List of custom series renderers used to draw series data on the chart. + // + // Series assigned a rendererIdKey will be drawn with the matching renderer in + // this list. Series without a rendererIdKey will be drawn by the default + // renderer. + final List customSeriesRenderers; + + /// The spec to use if RTL is enabled. + final common.RTLSpec rtlSpec; + + BaseChart(this.seriesList, + {bool animate, + Duration animationDuration, + this.defaultRenderer, + this.customSeriesRenderers, + this.behaviors, + this.selectionModels, + this.rtlSpec, + this.defaultInteractions = true, + this.layoutConfig}) + : this.animate = animate ?? true, + this.animationDuration = + animationDuration ?? const Duration(milliseconds: 750); + + @override + BaseChartState createState() => new BaseChartState(); + + /// Creates and returns a [common.BaseChart]. + common.BaseChart createCommonChart(BaseChartState chartState); + + /// Updates the [common.BaseChart]. + void updateCommonChart( + common.BaseChart chart, BaseChart oldWidget, BaseChartState chartState) { + common.Performance.time('chartsUpdateRenderers'); + // Set default renderer if one was provided. + if (defaultRenderer != null && + defaultRenderer != oldWidget?.defaultRenderer) { + chart.defaultRenderer = defaultRenderer.build(); + } + + // Add custom series renderers if any were provided. + if (customSeriesRenderers != null) { + // TODO: This logic does not remove old renderers and + // shouldn't require the series configs to remain in the same order. + for (var i = 0; i < customSeriesRenderers.length; i++) { + if (oldWidget == null || + (oldWidget.customSeriesRenderers != null && + i > oldWidget.customSeriesRenderers.length) || + customSeriesRenderers[i] != oldWidget.customSeriesRenderers[i]) { + chart.addSeriesRenderer(customSeriesRenderers[i].build()); + } + } + } + common.Performance.timeEnd('chartsUpdateRenderers'); + + common.Performance.time('chartsUpdateBehaviors'); + _updateBehaviors(chart, chartState); + common.Performance.timeEnd('chartsUpdateBehaviors'); + + _updateSelectionModel(chart, chartState); + + chart.transition = animate ? animationDuration : Duration.ZERO; + } + + void _updateBehaviors(common.BaseChart chart, BaseChartState chartState) { + final behaviorList = behaviors != null + ? new List.from(behaviors) + : []; + + // Insert automatic behaviors to the front of the behavior list. + if (defaultInteractions) { + if (chartState.autoBehaviorWidgets.isEmpty) { + addDefaultInteractions(chartState.autoBehaviorWidgets); + } + + // Add default interaction behaviors to the front of the list if they + // don't conflict with user behaviors by role. + chartState.autoBehaviorWidgets.reversed + .where(_notACustomBehavior) + .forEach((ChartBehavior behavior) { + behaviorList.insert(0, behavior); + }); + } + + // Remove any behaviors from the chart that are not in the incoming list. + // Walk in reverse order they were added. + // Also, remove any persisting behaviors from incoming list. + for (int i = chartState.addedBehaviorWidgets.length - 1; i >= 0; i--) { + final addedBehavior = chartState.addedBehaviorWidgets[i]; + if (!behaviorList.remove(addedBehavior)) { + final role = addedBehavior.role; + chartState.addedBehaviorWidgets.remove(addedBehavior); + chartState.addedCommonBehaviorsByRole.remove(role); + chart.removeBehavior(chartState.addedCommonBehaviorsByRole[role]); + } + } + + // Add any remaining/new behaviors. + behaviorList.forEach((ChartBehavior behaviorWidget) { + final commonBehavior = behaviorWidget.createCommonBehavior(); + + // Assign the chart state to any behavior that needs it. + if (commonBehavior is ChartStateBehavior) { + (commonBehavior as ChartStateBehavior).chartState = chartState; + } + + chart.addBehavior(commonBehavior); + chartState.addedBehaviorWidgets.add(behaviorWidget); + chartState.addedCommonBehaviorsByRole[behaviorWidget.role] = + commonBehavior; + }); + } + + /// Create the list of default interaction behaviors. + void addDefaultInteractions(List behaviors) { + // Update selection model + behaviors.add(new SelectNearest( + eventTrigger: common.SelectNearestTrigger.tap, + selectionModelType: common.SelectionModelType.info, + expandToDomain: true, + selectClosestSeries: true)); + } + + bool _notACustomBehavior(ChartBehavior behavior) { + return this.behaviors == null || + !this.behaviors.any( + (ChartBehavior userBehavior) => userBehavior.role == behavior.role); + } + + void _updateSelectionModel( + common.BaseChart chart, BaseChartState chartState) { + final prevTypes = new List.from( + chartState.addedSelectionListenersByType.keys); + + // Update any listeners for each type. + selectionModels?.forEach((SelectionModelConfig model) { + final prevListener = chartState.addedSelectionListenersByType[model.type]; + + if (!identical(model.listener, prevListener)) { + final selectionModel = chart.getSelectionModel(model.type); + selectionModel.removeSelectionListener(prevListener); + selectionModel.addSelectionListener(model.listener); + } + chartState.addedSelectionListenersByType[model.type] = model.listener; + + prevTypes.remove(model.type); + }); + + // Remove any lingering listeners. + prevTypes.forEach((common.SelectionModelType type) { + chart.getSelectionModel(type).removeSelectionListener( + chartState.addedSelectionListenersByType[type]); + }); + } + + /// Gets distinct set of gestures this chart will subscribe to. + /// + /// This is needed to allow setup of the [GestureDetector] widget with only + /// gestures we need to listen to and it must wrap [ChartContainer] widget. + /// Gestures are then setup to be proxied in [common.BaseChart] and that is + /// held by [ChartContainerRenderObject]. + Set getDesiredGestures(BaseChartState chartState) { + final types = new Set(); + behaviors?.forEach((ChartBehavior behavior) { + types.addAll(behavior.desiredGestures); + }); + + if (defaultInteractions && chartState.autoBehaviorWidgets.isEmpty) { + addDefaultInteractions(chartState.autoBehaviorWidgets); + } + + chartState.autoBehaviorWidgets.forEach((ChartBehavior behavior) { + types.addAll(behavior.desiredGestures); + }); + return types; + } +} + +@immutable +class LayoutConfig { + final common.MarginSpec leftMarginSpec; + final common.MarginSpec topMarginSpec; + final common.MarginSpec rightMarginSpec; + final common.MarginSpec bottomMarginSpec; + + LayoutConfig({ + @required this.leftMarginSpec, + @required this.topMarginSpec, + @required this.rightMarginSpec, + @required this.bottomMarginSpec, + }); + + common.LayoutConfig get commonLayoutConfig => new common.LayoutConfig( + leftSpec: leftMarginSpec, + topSpec: topMarginSpec, + rightSpec: rightMarginSpec, + bottomSpec: bottomMarginSpec); +} diff --git a/charts_flutter/lib/src/base_chart_state.dart b/charts_flutter/lib/src/base_chart_state.dart new file mode 100644 index 000000000..f7a5f0bd8 --- /dev/null +++ b/charts_flutter/lib/src/base_chart_state.dart @@ -0,0 +1,139 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:ui' show TextDirection; +import 'package:flutter/material.dart' + show + AnimationController, + BuildContext, + State, + TickerProviderStateMixin, + Widget; +import 'package:charts_common/common.dart' as common; +import 'package:flutter/widgets.dart' + show Directionality, LayoutId, CustomMultiChildLayout; +import 'behaviors/chart_behavior.dart' show BuildableBehavior, ChartBehavior; +import 'base_chart.dart' show BaseChart; +import 'chart_container.dart' show ChartContainer; +import 'chart_state.dart' show ChartState; +import 'chart_gesture_detector.dart' show ChartGestureDetector; +import 'widget_layout_delegate.dart'; + +class BaseChartState extends State + with TickerProviderStateMixin + implements ChartState { + // Animation + AnimationController _animationController; + double _animationValue = 0.0; + + Widget _oldWidget; + + ChartGestureDetector _chartGestureDetector; + + final autoBehaviorWidgets = []; + final addedBehaviorWidgets = []; + final addedCommonBehaviorsByRole = {}; + + final addedSelectionListenersByType = + {}; + + static const chartContainerLayoutID = 'chartContainer'; + + @override + void initState() { + super.initState(); + _animationController = new AnimationController(vsync: this) + ..addListener(_animationTick); + } + + @override + void requestRebuild() { + setState(() {}); + } + + /// Builds the common chart canvas widget. + Widget _buildChartContainer() { + final chartContainer = new ChartContainer( + oldChartWidget: _oldWidget, + chartWidget: widget, + chartState: this, + animationValue: _animationValue, + rtl: Directionality.of(context) == TextDirection.rtl, + rtlSpec: widget.rtlSpec); + _oldWidget = widget; + + final desiredGestures = widget.getDesiredGestures(this); + if (desiredGestures.isNotEmpty) { + _chartGestureDetector ??= new ChartGestureDetector(); + return _chartGestureDetector.makeWidget( + context, chartContainer, desiredGestures); + } else { + return chartContainer; + } + } + + @override + Widget build(BuildContext context) { + final chartWidgets = []; + final idAndBehaviorMap = {}; + + // Add the common chart canvas widget. + chartWidgets.add(new LayoutId( + id: chartContainerLayoutID, child: _buildChartContainer())); + + // Add widget for each behavior that can build widgets + addedCommonBehaviorsByRole.forEach((id, behavior) { + if (behavior is BuildableBehavior) { + assert(id != chartContainerLayoutID); + + final buildableBehavior = behavior as BuildableBehavior; + idAndBehaviorMap[id] = buildableBehavior; + + final widget = buildableBehavior.build(context); + chartWidgets.add(new LayoutId(id: id, child: widget)); + } + }); + + final isRTL = Directionality.of(context) == TextDirection.rtl; + + return new CustomMultiChildLayout( + delegate: new WidgetLayoutDelegate( + chartContainerLayoutID, idAndBehaviorMap, isRTL), + children: chartWidgets); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + void setAnimation(Duration transition) { + _playAnimation(transition); + } + + void _playAnimation(Duration duration) { + _animationController.duration = duration; + _animationController.forward(from: (duration == Duration.ZERO) ? 1.0 : 0.0); + _animationValue = _animationController.value; + } + + void _animationTick() { + setState(() { + _animationValue = _animationController.value; + }); + } +} diff --git a/charts_flutter/lib/src/behaviors/a11y/domain_a11y_explore_behavior.dart b/charts_flutter/lib/src/behaviors/a11y/domain_a11y_explore_behavior.dart new file mode 100644 index 000000000..2def2da46 --- /dev/null +++ b/charts_flutter/lib/src/behaviors/a11y/domain_a11y_explore_behavior.dart @@ -0,0 +1,110 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show DomainA11yExploreBehavior, VocalizationCallback, ExploreModeTrigger; + +import '../chart_behavior.dart' show ChartBehavior, GestureType; + +/// Behavior that generates semantic nodes for each domain. +class DomainA11yExploreBehavior + extends ChartBehavior { + /// Returns a string for a11y vocalization from a list of series datum. + final common.VocalizationCallback vocalizationCallback; + + final Set desiredGestures; + + /// The gesture that activates explore mode. Defaults to long press. + /// + /// Turning on explore mode asks this [A11yBehavior] to generate nodes within + /// this chart. + final common.ExploreModeTrigger exploreModeTrigger; + + /// Minimum width of the bounding box for the a11y focus. + /// + /// Must be 1 or higher because invisible semantic nodes should not be added. + final double minimumWidth; + + /// Optionally notify the OS when explore mode is enabled. + final String exploreModeEnabledAnnouncement; + + /// Optionally notify the OS when explore mode is disabled. + final String exploreModeDisabledAnnouncement; + + DomainA11yExploreBehavior._internal( + {this.vocalizationCallback, + this.exploreModeTrigger, + this.desiredGestures, + this.minimumWidth, + this.exploreModeEnabledAnnouncement, + this.exploreModeDisabledAnnouncement}); + + factory DomainA11yExploreBehavior( + {common.VocalizationCallback vocalizationCallback, + common.ExploreModeTrigger exploreModeTrigger, + double minimumWidth, + String exploreModeEnabledAnnouncement, + String exploreModeDisabledAnnouncement}) { + final desiredGestures = new Set(); + exploreModeTrigger ??= common.ExploreModeTrigger.pressHold; + + switch (exploreModeTrigger) { + case common.ExploreModeTrigger.pressHold: + desiredGestures..add(GestureType.onLongPress); + break; + case common.ExploreModeTrigger.tap: + desiredGestures..add(GestureType.onTap); + break; + } + + return new DomainA11yExploreBehavior._internal( + vocalizationCallback: vocalizationCallback, + desiredGestures: desiredGestures, + exploreModeTrigger: exploreModeTrigger, + minimumWidth: minimumWidth, + exploreModeEnabledAnnouncement: exploreModeEnabledAnnouncement, + exploreModeDisabledAnnouncement: exploreModeDisabledAnnouncement, + ); + } + + @override + common.DomainA11yExploreBehavior createCommonBehavior() { + return new common.DomainA11yExploreBehavior( + vocalizationCallback: vocalizationCallback, + exploreModeTrigger: exploreModeTrigger, + minimumWidth: minimumWidth, + exploreModeEnabledAnnouncement: exploreModeEnabledAnnouncement, + exploreModeDisabledAnnouncement: exploreModeDisabledAnnouncement); + } + + @override + void updateCommonBehavior(common.DomainA11yExploreBehavior commonBehavior) {} + + @override + String get role => 'DomainA11yExplore-${exploreModeTrigger}'; + + @override + bool operator ==(Object o) => + o is DomainA11yExploreBehavior && + exploreModeTrigger == o.exploreModeTrigger && + minimumWidth == minimumWidth; + + @override + int get hashCode { + var hashCode = minimumWidth.hashCode; + hashCode = hashCode * 37 + exploreModeTrigger.hashCode; + return hashCode; + } +} diff --git a/charts_flutter/lib/src/behaviors/chart_behavior.dart b/charts_flutter/lib/src/behaviors/chart_behavior.dart new file mode 100644 index 000000000..562c204ae --- /dev/null +++ b/charts_flutter/lib/src/behaviors/chart_behavior.dart @@ -0,0 +1,104 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:charts_common/common.dart' as common show ChartBehavior; +import 'package:meta/meta.dart' show immutable; +import 'package:flutter/widgets.dart' show BuildContext, Widget; + +import '../base_chart_state.dart' show BaseChartState; + +/// Flutter wrapper for chart behaviors. +@immutable +abstract class ChartBehavior { + Set get desiredGestures; + + B createCommonBehavior(); + + void updateCommonBehavior(B commonBehavior); + + String get role; +} + +/// A chart behavior that depends on Flutter [State]. +abstract class ChartStateBehavior { + BaseChartState get chartState; + + set chartState(BaseChartState chartState); +} + +/// A chart behavior that can build a Flutter [Widget]. +abstract class BuildableBehavior { + /// Builds a [Widget] based on the information passed in. + /// + /// [context] Flutter build context for extracting inherited properties such + /// as Directionality. + Widget build(BuildContext context); + + /// The position on the widget. + BuildablePosition get position; + + /// Justification of the widget, if [position] is top, bottom, start, or end. + OutsideJustification get outsideJustification; + + /// Justification of the widget if [position] is [BuildablePosition.inside]. + InsideJustification get insideJustification; + + /// Chart's draw area bounds are used for positioning. + Rectangle get drawAreaBounds; +} + +/// Position of a widget within the custom chart layout. +/// +/// Outside positions are [top], [bottom], [start], and [end]. +/// +/// [top] widget positioned at the top, with the chart positioned below the +/// widget and height reduced by the height of the widget. +/// [bottom] widget positioned below the chart, and the chart's height is +/// reduced by the height of the widget. +/// [start] widget is positioned at the left of the chart (or the right if RTL), +/// the chart's width is reduced by the width of the widget. +/// [end] widget is positioned at the right of the chart (or the left if RTL), +/// the chart's width is reduced by the width of the widget. +/// [inside] widget is layered on top of the chart. +enum BuildablePosition { + top, + bottom, + start, + end, + inside, +} + +/// Justification for buildable widgets positioned outside [BuildablePosition]. +enum OutsideJustification { + startDrawArea, + start, + endDrawArea, + end, +} + +/// Justification for buildable widgets positioned [BuildablePosition.inside]. +enum InsideJustification { + topStart, + topEnd, +} + +/// Types of gestures accepted by a chart. +enum GestureType { + onLongPress, + onTap, + onHover, + onDrag, +} diff --git a/charts_flutter/lib/src/behaviors/domain_highlighter.dart b/charts_flutter/lib/src/behaviors/domain_highlighter.dart new file mode 100644 index 000000000..b56fa448a --- /dev/null +++ b/charts_flutter/lib/src/behaviors/domain_highlighter.dart @@ -0,0 +1,54 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show DomainHighlighter, SelectionModelType; + +import 'package:meta/meta.dart' show immutable; + +import 'chart_behavior.dart' show ChartBehavior, GestureType; + +/// Chart behavior that monitors the specified [SelectionModel] and darkens the +/// color for selected data. +/// +/// This is typically used for bars and pies to highlight segments. +/// +/// It is used in combination with SelectNearest to update the selection model +/// and expand selection out to the domain value. +@immutable +class DomainHighlighter extends ChartBehavior { + final desiredGestures = new Set(); + + final common.SelectionModelType selectionModelType; + + DomainHighlighter([this.selectionModelType = common.SelectionModelType.info]); + + @override + common.DomainHighlighter createCommonBehavior() => + new common.DomainHighlighter(selectionModelType); + + @override + void updateCommonBehavior(common.DomainHighlighter commonBehavior) {} + + @override + String get role => 'domainHighlight-${selectionModelType.toString()}'; + + @override + bool operator ==(Object o) => + o is DomainHighlighter && selectionModelType == o.selectionModelType; + + @override + int get hashCode => selectionModelType.hashCode; +} diff --git a/charts_flutter/lib/src/behaviors/legend/legend_content_builder.dart b/charts_flutter/lib/src/behaviors/legend/legend_content_builder.dart new file mode 100644 index 000000000..6b6017c8d --- /dev/null +++ b/charts_flutter/lib/src/behaviors/legend/legend_content_builder.dart @@ -0,0 +1,69 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common show LegendState; +import 'package:flutter/widgets.dart' show BuildContext, Widget; +import 'legend_entry_layout.dart'; +import 'legend_layout.dart'; + +/// Strategy for building a legend content widget. +abstract class LegendContentBuilder { + const LegendContentBuilder(); + + Widget build(BuildContext context, common.LegendState legendState); +} + +/// Base strategy for building a legend content widget. +/// +/// Each legend entry is passed to a [LegendLayout] strategy to create a widget +/// for each legend entry. These widgets are then passed to a +/// [LegendEntryLayout] strategy to create the legend widget. +abstract class BaseLegendContentBuilder implements LegendContentBuilder { + /// Strategy for creating one widget or each legend entry. + LegendEntryLayout get legendEntryLayout; + + /// Strategy for creating the legend content widget from a list of widgets. + /// + /// This is typically the list of widgets from legend entries. + LegendLayout get legendLayout; + + @override + Widget build(BuildContext context, common.LegendState legendState) { + final entryWidgets = legendState.legendEntries + .map((entry) => legendEntryLayout.build(context, entry)) + .toList(); + + return legendLayout.build(context, entryWidgets); + } +} + +// TODO: Expose settings for tabular layout. +/// Strategy that builds a tabular legend. +/// +/// [legendEntryLayout] custom strategy for creating widgets for each legend +/// entry. +/// [legendLayout] custom strategy for creating legend widget from list of +/// widgets that represent a legend entry. +class TabularLegendContentBuilder extends BaseLegendContentBuilder { + final LegendEntryLayout legendEntryLayout; + final LegendLayout legendLayout; + + TabularLegendContentBuilder( + {LegendEntryLayout legendEntryLayout, LegendLayout legendLayout}) + : this.legendEntryLayout = + legendEntryLayout ?? const SimpleLegendEntryLayout(), + this.legendLayout = + legendLayout ?? new TabularLegendLayout.horizontalFirst(); +} diff --git a/charts_flutter/lib/src/behaviors/legend/legend_entry_layout.dart b/charts_flutter/lib/src/behaviors/legend/legend_entry_layout.dart new file mode 100644 index 000000000..33a4c2587 --- /dev/null +++ b/charts_flutter/lib/src/behaviors/legend/legend_entry_layout.dart @@ -0,0 +1,61 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common; +import 'package:flutter/widgets.dart'; +import '../../symbol_renderer.dart'; + +/// Strategy for building one widget from one [common.LegendEntry]. +abstract class LegendEntryLayout { + Widget build(BuildContext context, common.LegendEntry legendEntry); +} + +/// Builds one legend entry as a row with symbol and label from the series. +/// +/// If directionality from the chart context indicates RTL, the symbol is placed +/// to the right of the text instead of the left of the text. +class SimpleLegendEntryLayout implements LegendEntryLayout { + const SimpleLegendEntryLayout(); + + Widget createSymbol(BuildContext context, common.LegendEntry legendEntry) { + // TODO: Consider allowing scaling the size for the symbol. + // A custom symbol renderer can ignore this size and use their own. + final materialSymbolSize = new Size(12.0, 12.0); + + final SymbolRenderer symbolRenderer = + legendEntry.symbolRenderer ?? new RoundedRectSymbolRenderer(); + final color = legendEntry.color; + + return symbolRenderer.build( + context, + size: materialSymbolSize, + color: new Color.fromARGB(color.a, color.r, color.g, color.b), + ); + } + + Widget createLabel(BuildContext context, common.LegendEntry legendEntry) => + new Text(legendEntry.label); + + @override + Widget build(BuildContext context, common.LegendEntry legendEntry) { + // TODO: Allow setting to configure the padding. + final padding = new EdgeInsets.only(right: 8.0); // Material default. + final symbol = createSymbol(context, legendEntry); + final label = createLabel(context, legendEntry); + + // Row automatically reverses the content if Directionality is rtl. + return new Row(children: [symbol, new Container(padding: padding), label]); + } +} diff --git a/charts_flutter/lib/src/behaviors/legend/legend_layout.dart b/charts_flutter/lib/src/behaviors/legend/legend_layout.dart new file mode 100644 index 000000000..4b45fd414 --- /dev/null +++ b/charts_flutter/lib/src/behaviors/legend/legend_layout.dart @@ -0,0 +1,158 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show min; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; + +/// Strategy for building legend from legend entry widgets. +abstract class LegendLayout { + Widget build(BuildContext context, List legendEntryWidgets); +} + +/// Layout legend entries in tabular format. +class TabularLegendLayout implements LegendLayout { + /// No limit for max rows or max columns. + static const _noLimit = -1; + + final bool isHorizontalFirst; + final int desiredMaxRows; + final int desiredMaxColumns; + final EdgeInsets cellPadding; + + TabularLegendLayout._internal( + {this.isHorizontalFirst, + this.desiredMaxRows, + this.desiredMaxColumns, + this.cellPadding}); + + /// Layout horizontally until columns exceed [desiredMaxColumns]. + /// + /// [desiredMaxColumns] the max columns to use before laying out items in a + /// new row. By default there is no limit. The max columns created is the + /// smaller of desiredMaxColumns and number of legend entries. + /// + /// [cellPadding] the [EdgeInsets] for each widget. + factory TabularLegendLayout.horizontalFirst({ + int desiredMaxColumns, + EdgeInsets cellPadding, + }) { + return new TabularLegendLayout._internal( + isHorizontalFirst: true, + desiredMaxRows: _noLimit, + desiredMaxColumns: desiredMaxColumns ?? _noLimit, + cellPadding: cellPadding, + ); + } + + /// Layout vertically, until rows exceed [desiredMaxRows]. + /// + /// [desiredMaxRows] the max rows to use before layout out items in a new + /// column. By default there is no limit. The max columns created is the + /// smaller of desiredMaxRows and number of legend entries. + /// + /// [cellPadding] the [EdgeInsets] for each widget. + factory TabularLegendLayout.verticalFirst({ + int desiredMaxRows, + EdgeInsets cellPadding, + }) { + return new TabularLegendLayout._internal( + isHorizontalFirst: false, + desiredMaxRows: desiredMaxRows ?? _noLimit, + desiredMaxColumns: _noLimit, + cellPadding: cellPadding, + ); + } + + @override + Widget build(BuildContext context, List legendEntries) { + final paddedLegendEntries = ((cellPadding == null) + ? legendEntries + : legendEntries + .map((entry) => new Padding(padding: cellPadding, child: entry)) + .toList()); + + return isHorizontalFirst + ? _buildHorizontalFirst(paddedLegendEntries) + : _buildVerticalFirst(paddedLegendEntries); + } + + @override + bool operator ==(o) => + o is TabularLegendLayout && + desiredMaxRows == o.desiredMaxRows && + desiredMaxColumns == o.desiredMaxColumns && + isHorizontalFirst == o.isHorizontalFirst && + cellPadding == o.cellPadding; + + @override + int get hashCode => hashValues( + desiredMaxRows, desiredMaxColumns, isHorizontalFirst, cellPadding); + + Widget _buildHorizontalFirst(List legendEntries) { + final maxColumns = (desiredMaxColumns == _noLimit) + ? legendEntries.length + : min(legendEntries.length, desiredMaxColumns); + + final rows = []; + for (var i = 0; i < legendEntries.length; i += maxColumns) { + rows.add(new TableRow( + children: legendEntries + .sublist(i, min(i + maxColumns, legendEntries.length)) + .toList())); + } + + return _buildTableFromRows(rows); + } + + Widget _buildVerticalFirst(List legendEntries) { + final maxRows = (desiredMaxRows == _noLimit) + ? legendEntries.length + : min(legendEntries.length, desiredMaxRows); + + final rows = + new List.generate(maxRows, (_) => new TableRow(children: [])); + for (var i = 0; i < legendEntries.length; i++) { + rows[i % maxRows].children.add(legendEntries[i]); + } + + return _buildTableFromRows(rows); + } + + Table _buildTableFromRows(List rows) { + final padWidget = new Row(); + + // Pad rows to the max column count, because each TableRow in a table is + // required to have the same number of children. + final columnCount = rows + .map((r) => r.children.length) + .fold(0, (max, current) => (current > max) ? current : max); + + for (var i = 0; i < rows.length; i++) { + final rowChildren = rows[i].children; + final padCount = columnCount - rowChildren.length; + if (padCount > 0) { + rowChildren.addAll(new Iterable.generate(padCount, (_) => padWidget)); + } + } + + // TODO: Investigate other means of creating the tabular legend + // Sizing the column width using [IntrinsicColumnWidth] is expensive per + // Flutter's documentation, but has to be used if the table is desired to + // have a width that is tight on each column. + return new Table( + children: rows, defaultColumnWidth: new IntrinsicColumnWidth()); + } +} diff --git a/charts_flutter/lib/src/behaviors/legend/series_legend.dart b/charts_flutter/lib/src/behaviors/legend/series_legend.dart new file mode 100644 index 000000000..5812bf560 --- /dev/null +++ b/charts_flutter/lib/src/behaviors/legend/series_legend.dart @@ -0,0 +1,165 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show SeriesLegend, SelectionModelType; +import 'package:flutter/widgets.dart' show BuildContext, EdgeInsets, Widget; +import 'package:meta/meta.dart' show immutable; +import '../../chart_container.dart' show ChartContainerRenderObject; +import '../chart_behavior.dart' + show + BuildableBehavior, + BuildablePosition, + ChartBehavior, + GestureType, + InsideJustification, + OutsideJustification; +import 'legend_content_builder.dart' + show LegendContentBuilder, TabularLegendContentBuilder; +import 'legend_layout.dart' show TabularLegendLayout; + +/// Series legend behavior for charts. +@immutable +class SeriesLegend extends ChartBehavior { + final desiredGestures = new Set(); + + final common.SelectionModelType selectionModelType; + + /// Builder for creating custom legend content. + final LegendContentBuilder contentBuilder; + + /// Position of the legend relative to the chart. + final BuildablePosition position; + + /// Justification of the legend relative to the chart + final OutsideJustification outsideJustification; + final InsideJustification insideJustification; + + static const defaultCellPadding = const EdgeInsets.all(8.0); + + /// Create a new tabular layout legend. + /// + /// By default, the legend is place above the chart and horizontally aligned + /// to the start of the draw area. + /// + /// [position] the legend will be positioned relative to the chart. Default + /// position is top. + /// + /// [outsideJustification] justification of the legend relative to the chart + /// if the position is top, bottom, left, right. Default to start of the draw + /// area. + /// + /// [insideJustification] justification of the legend relative to the chart if + /// the position is inside. Default to top of the chart, start of draw area. + /// Start of draw area means left for LTR directionality, and right for RTL. + /// + /// [horizontalFirst] if true, legend entries will grow horizontally first + /// instead of vertically first. If the position is top, bottom, or inside, + /// this defaults to true. Otherwise false. + /// + /// [desiredMaxRows] the max rows to use before layout out items in a new + /// column. By default there is no limit. The max columns created is the + /// smaller of desiredMaxRows and number of legend entries. + /// + /// [desiredMaxColumns] the max columns to use before laying out items in a + /// new row. By default there is no limit. The max columns created is the + /// smaller of desiredMaxColumns and number of legend entries. + factory SeriesLegend({ + BuildablePosition position, + OutsideJustification outsideJustification, + InsideJustification insideJustification, + bool horizontalFirst, + int desiredMaxRows, + int desiredMaxColumns, + EdgeInsets cellPadding, + }) { + // Set defaults if empty. + position ??= BuildablePosition.top; + outsideJustification ??= OutsideJustification.startDrawArea; + insideJustification ??= InsideJustification.topStart; + cellPadding ??= defaultCellPadding; + + // Set the tabular layout settings to match the position if it is not + // specified. + horizontalFirst ??= (position == BuildablePosition.top || + position == BuildablePosition.bottom || + position == BuildablePosition.inside); + final layoutBuilder = horizontalFirst + ? new TabularLegendLayout.horizontalFirst( + desiredMaxColumns: desiredMaxColumns, cellPadding: cellPadding) + : new TabularLegendLayout.verticalFirst( + desiredMaxRows: desiredMaxRows, cellPadding: cellPadding); + + return new SeriesLegend._internal( + contentBuilder: + new TabularLegendContentBuilder(legendLayout: layoutBuilder), + selectionModelType: common.SelectionModelType.info, + position: position, + outsideJustification: outsideJustification, + insideJustification: insideJustification); + } + + SeriesLegend._internal( + {this.contentBuilder, + this.selectionModelType, + this.position, + this.outsideJustification, + this.insideJustification}); + + @override + common.SeriesLegend createCommonBehavior() => new _FlutterSeriesLegend(this); + + @override + void updateCommonBehavior(common.SeriesLegend commonBehavior) { + (commonBehavior as _FlutterSeriesLegend).config = this; + } + + @override + String get role => 'legend-${selectionModelType.toString()}'; + + @override + bool operator ==(Object o) => + o is SeriesLegend && selectionModelType == o.selectionModelType; + + @override + int get hashCode => selectionModelType.hashCode; +} + +/// Flutter specific wrapper on the common Legend for building content. +class _FlutterSeriesLegend extends common.SeriesLegend + implements BuildableBehavior { + SeriesLegend config; + + _FlutterSeriesLegend(this.config) + : super(selectionModelType: config.selectionModelType); + + @override + void updateLegend() { + (chartContext as ChartContainerRenderObject).requestRebuild(); + } + + @override + BuildablePosition get position => config.position; + + @override + OutsideJustification get outsideJustification => config.outsideJustification; + + @override + InsideJustification get insideJustification => config.insideJustification; + + @override + Widget build(BuildContext context) => + config.contentBuilder.build(context, legendState); +} diff --git a/charts_flutter/lib/src/behaviors/line_point_highlighter.dart b/charts_flutter/lib/src/behaviors/line_point_highlighter.dart new file mode 100644 index 000000000..3e18af63d --- /dev/null +++ b/charts_flutter/lib/src/behaviors/line_point_highlighter.dart @@ -0,0 +1,80 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show LinePointHighlighter, SelectionModelType; + +import 'package:meta/meta.dart' show immutable; + +import 'chart_behavior.dart' show ChartBehavior, GestureType; + +/// Chart behavior that monitors the specified [SelectionModel] and darkens the +/// color for selected data. +/// +/// This is typically used for bars and pies to highlight segments. +/// +/// It is used in combination with SelectNearest to update the selection model +/// and expand selection out to the domain value. +@immutable +class LinePointHighlighter extends ChartBehavior { + final desiredGestures = new Set(); + + final common.SelectionModelType selectionModelType; + + /// Default radius of the dots if the series has no radius mapping function. + /// + /// When no radius mapping function is provided, this value will be used as + /// is. [radiusPaddingPx] will not be added to [defaultRadiusPx]. + final double defaultRadiusPx; + + /// Additional radius value added to the radius of the selected data. + /// + /// This value is only used when the series has a radius mapping function + /// defined. + final double radiusPaddingPx; + + final bool showHorizontalFollowLine; + + final bool showVerticalFollowLine; + + LinePointHighlighter( + {this.selectionModelType = common.SelectionModelType.info, + this.defaultRadiusPx = 4.0, + this.radiusPaddingPx = 0.5, + this.showHorizontalFollowLine = false, + this.showVerticalFollowLine = true}); + + @override + common.LinePointHighlighter createCommonBehavior() => + new common.LinePointHighlighter( + selectionModelType: selectionModelType, + defaultRadiusPx: defaultRadiusPx, + radiusPaddingPx: radiusPaddingPx, + showHorizontalFollowLine: showHorizontalFollowLine, + showVerticalFollowLine: showVerticalFollowLine); + + @override + void updateCommonBehavior(common.LinePointHighlighter commonBehavior) {} + + @override + String get role => 'LinePointHighlighter-${selectionModelType.toString()}'; + + @override + bool operator ==(Object o) => + o is LinePointHighlighter && selectionModelType == o.selectionModelType; + + @override + int get hashCode => selectionModelType.hashCode; +} diff --git a/charts_flutter/lib/src/behaviors/range_annotation.dart b/charts_flutter/lib/src/behaviors/range_annotation.dart new file mode 100644 index 000000000..7b6dcd6bc --- /dev/null +++ b/charts_flutter/lib/src/behaviors/range_annotation.dart @@ -0,0 +1,60 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show Color, MaterialPalette, RangeAnnotation, RangeAnnotationSegment; + +import 'package:meta/meta.dart' show immutable; + +import 'chart_behavior.dart' show ChartBehavior, GestureType; + +/// Chart behavior that annotations domain ranges with a solid fill color. +/// +/// The annotations will be drawn underneath series data and chart axes. +/// +/// This is typically used for line charts to call out sections of the data +/// range. +@immutable +class RangeAnnotation extends ChartBehavior { + final desiredGestures = new Set(); + + /// List of annotations to render on the chart. + final List annotations; + + /// Default color for annotations. + final common.Color defaultColor; + + /// Whether or not the range of the axis should be extended to include the + /// annotation start and end values. + final bool extendAxis; + + RangeAnnotation(this.annotations, + {common.Color defaultColor, this.extendAxis = true}) + : defaultColor = common.MaterialPalette.gray.shade100; + + @override + common.RangeAnnotation createCommonBehavior() => + new common.RangeAnnotation(annotations, + defaultColor: defaultColor, extendAxis: extendAxis); + + @override + void updateCommonBehavior(common.RangeAnnotation commonBehavior) {} + + @override + String get role => 'RangeAnnotation'; + + @override + bool operator ==(Object o) => o is RangeAnnotation; +} diff --git a/charts_flutter/lib/src/behaviors/select_nearest.dart b/charts_flutter/lib/src/behaviors/select_nearest.dart new file mode 100644 index 000000000..a1856917d --- /dev/null +++ b/charts_flutter/lib/src/behaviors/select_nearest.dart @@ -0,0 +1,137 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show ChartBehavior, SelectNearest, SelectionModelType, SelectNearestTrigger; + +import 'package:meta/meta.dart' show immutable; + +import 'chart_behavior.dart' show ChartBehavior, GestureType; + +/// Chart behavior that listens to the given eventTrigger and updates the +/// specified [SelectionModel]. This is used to pair input events to behaviors +/// that listen to selection changes. +/// +/// Input event types: +/// hover (default) - Mouse over/near data. +/// tap - Mouse/Touch on/near data. +/// pressHold - Mouse/Touch and drag across the data instead of panning. +/// longPressHold - Mouse/Touch for a while in one place then drag across the data. +/// +/// SelectionModels that can be updated: +/// info - To view the details of the selected items (ie: hover for web). +/// action - To select an item as an input, drill, or other selection. +/// +/// Other options available +/// expandToDomain - all data points that match the domain value of the +/// closest data point will be included in the selection. (Default: true) +/// selectClosestSeries - mark the series for the closest data point as +/// selected. (Default: true) +/// +/// You can add one SelectNearest for each model type that you are updating. +/// Any previous SelectNearest behavior for that selection model will be +/// removed. +@immutable +class SelectNearest extends ChartBehavior { + final Set desiredGestures; + + final common.SelectionModelType selectionModelType; + final common.SelectNearestTrigger eventTrigger; + final bool expandToDomain; + final bool selectClosestSeries; + + SelectNearest._internal( + {this.selectionModelType, + this.expandToDomain = true, + this.selectClosestSeries = true, + this.eventTrigger, + this.desiredGestures}); + + factory SelectNearest( + {common.SelectionModelType selectionModelType = + common.SelectionModelType.info, + bool expandToDomain = true, + bool selectClosestSeries = true, + common.SelectNearestTrigger eventTrigger = + common.SelectNearestTrigger.tap}) { + return new SelectNearest._internal( + selectionModelType: selectionModelType, + expandToDomain: expandToDomain, + selectClosestSeries: selectClosestSeries, + eventTrigger: eventTrigger, + desiredGestures: SelectNearest._getDesiredGestures(eventTrigger)); + } + + static Set _getDesiredGestures( + common.SelectNearestTrigger eventTrigger) { + final desiredGestures = new Set(); + switch (eventTrigger) { + case common.SelectNearestTrigger.tap: + desiredGestures..add(GestureType.onTap); + break; + case common.SelectNearestTrigger.tapAndDrag: + desiredGestures..add(GestureType.onTap)..add(GestureType.onDrag); + break; + case common.SelectNearestTrigger.pressHold: + case common.SelectNearestTrigger.longPressHold: + desiredGestures + ..add(GestureType.onTap) + ..add(GestureType.onLongPress) + ..add(GestureType.onDrag); + break; + case common.SelectNearestTrigger.hover: + default: + desiredGestures..add(GestureType.onHover); + break; + } + return desiredGestures; + } + + @override + common.SelectNearest createCommonBehavior() { + return new common.SelectNearest( + selectionModelType: selectionModelType, + eventTrigger: eventTrigger, + expandToDomain: expandToDomain, + selectClosestSeries: selectClosestSeries); + } + + @override + void updateCommonBehavior(common.ChartBehavior commonBehavior) {} + + // TODO: Explore the performance impact of calculating this once + // at the constructor for this and common ChartBehaviors. + @override + String get role => 'SelectNearest-${selectionModelType.toString()}}'; + + bool operator ==(Object other) { + if (other is SelectNearest) { + return (selectionModelType == other.selectionModelType) && + (eventTrigger == other.eventTrigger) && + (expandToDomain == other.expandToDomain) && + (selectClosestSeries == other.selectClosestSeries); + } else { + return false; + } + } + + int get hashCode { + int hashcode = selectionModelType.hashCode; + hashcode = hashcode * 37 + eventTrigger.hashCode; + hashcode = hashcode * 37 + expandToDomain.hashCode; + hashcode = hashcode * 37 + selectClosestSeries.hashCode; + return hashcode; + } +} diff --git a/charts_flutter/lib/src/behaviors/zoom/pan_and_zoom_behavior.dart b/charts_flutter/lib/src/behaviors/zoom/pan_and_zoom_behavior.dart new file mode 100644 index 000000000..3fd16e0ce --- /dev/null +++ b/charts_flutter/lib/src/behaviors/zoom/pan_and_zoom_behavior.dart @@ -0,0 +1,52 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show ChartBehavior, PanAndZoomBehavior; +import 'package:meta/meta.dart' show immutable; + +import '../chart_behavior.dart' show ChartBehavior, GestureType; +import 'pan_behavior.dart' show FlutterPanBehavior; + +@immutable +class PanAndZoomBehavior extends ChartBehavior { + final _desiredGestures = new Set.from([ + GestureType.onDrag, + ]); + + Set get desiredGestures => _desiredGestures; + + @override + common.PanAndZoomBehavior createCommonBehavior() { + return new FlutterPanAndZoomBehavior(); + } + + @override + void updateCommonBehavior(common.ChartBehavior commonBehavior) {} + + @override + String get role => 'PanAndZoom'; + + bool operator ==(Object other) => other is PanAndZoomBehavior; + + int get hashCode { + return this.runtimeType.hashCode; + } +} + +/// Adds fling gesture support to [common.PanAndZoomBehavior], by way of +/// [FlutterPanBehavior]. +class FlutterPanAndZoomBehavior extends common.PanAndZoomBehavior + with FlutterPanBehavior {} diff --git a/charts_flutter/lib/src/behaviors/zoom/pan_behavior.dart b/charts_flutter/lib/src/behaviors/zoom/pan_behavior.dart new file mode 100644 index 000000000..ffebcb0c0 --- /dev/null +++ b/charts_flutter/lib/src/behaviors/zoom/pan_behavior.dart @@ -0,0 +1,170 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show max, pow, Point; +import 'dart:ui'; + +import 'package:flutter/widgets.dart' show AnimationController; + +import 'package:charts_common/common.dart' as common + show BaseChart, CartesianChart, ChartBehavior, PanBehavior; +import 'package:meta/meta.dart' show immutable; + +import '../../base_chart_state.dart' show BaseChartState; +import '../chart_behavior.dart' + show ChartBehavior, ChartStateBehavior, GestureType; + +@immutable +class PanBehavior extends ChartBehavior { + final _desiredGestures = new Set.from([ + GestureType.onDrag, + ]); + + Set get desiredGestures => _desiredGestures; + + @override + common.PanBehavior createCommonBehavior() { + return new FlutterPanBehavior(); + } + + @override + void updateCommonBehavior(common.ChartBehavior commonBehavior) {} + + @override + String get role => 'Pan'; + + bool operator ==(Object other) => other is PanBehavior; + + int get hashCode { + return this.runtimeType.hashCode; + } +} + +/// Adds fling gesture support to [common.PanBehavior]. +class FlutterPanBehavior extends common.PanBehavior + implements ChartStateBehavior { + BaseChartState _chartState; + + BaseChartState get chartState => _chartState; + + set chartState(BaseChartState chartState) { + _chartState = chartState; + + if (_chartState != null) { + _flingAnimator = new AnimationController(vsync: _chartState) + ..addListener(_onFlingTick); + } else { + _flingAnimator = null; + } + } + + AnimationController _flingAnimator; + + double _flingAnimationInitialTranslatePx; + double _flingAnimationTargetTranslatePx; + + bool _isFlinging = false; + + static const flingDistanceMultiplier = 0.15; + static const flingDeceleratorFactor = 1.0; + static const flingDurationMultiplier = 0.15; + static const minimumFlingVelocity = 300.0; + + @override + removeFrom(common.BaseChart chart) { + stopFlingAnimation(); + _flingAnimator = null; + super.removeFrom(chart); + } + + @override + bool onTapTest(Point chartPoint) { + super.onTapTest(chartPoint); + + stopFlingAnimation(); + + return true; + } + + @override + bool onDragEnd( + Point localPosition, double scale, double pixelsPerSec) { + if (isPanning) { + // Ignore slow drag gestures to avoid jitter. + if (pixelsPerSec.abs() < minimumFlingVelocity) { + onPanEnd(); + return true; + } + + _startFling(pixelsPerSec); + } + return true; + } + + /// Starts a 'fling' in the direction and speed given by [pixelsPerSec]. + void _startFling(double pixelsPerSec) { + final domainAxis = (chart as common.CartesianChart).domainAxis; + + _flingAnimationInitialTranslatePx = domainAxis.viewportTranslatePx; + _flingAnimationTargetTranslatePx = _flingAnimationInitialTranslatePx + + pixelsPerSec * flingDistanceMultiplier; + + final flingDuration = new Duration( + milliseconds: + max(200, (pixelsPerSec * flingDurationMultiplier).abs().round())); + + _flingAnimator + ..duration = flingDuration + ..forward(from: 0.0); + _isFlinging = true; + } + + /// Decelerates a fling event. + double _decelerate(double value) => flingDeceleratorFactor == 1.0 + ? 1.0 - (1.0 - value) * (1.0 - value) + : 1.0 - pow(1.0 - value, 2 * flingDeceleratorFactor); + + /// Updates the chart axis state on each tick of the [AnimationController]. + void _onFlingTick() { + if (!_isFlinging) { + return; + } + + final percent = _flingAnimator.value; + final deceleratedPercent = _decelerate(percent); + final translation = lerpDouble(_flingAnimationInitialTranslatePx, + _flingAnimationTargetTranslatePx, deceleratedPercent); + + final domainAxis = (chart as common.CartesianChart).domainAxis; + + domainAxis.setViewportSettings( + domainAxis.viewportScalingFactor, translation, + drawAreaWidth: chart.drawAreaBounds.width); + + if (percent >= 1.0) { + stopFlingAnimation(); + onPanEnd(); + chart.redraw(); + } + } + + /// Stops any current fling animations that may be executing. + void stopFlingAnimation() { + if (_isFlinging) { + _isFlinging = false; + _flingAnimator.stop(); + } + } +} diff --git a/charts_flutter/lib/src/canvas/line_painter.dart b/charts_flutter/lib/src/canvas/line_painter.dart new file mode 100644 index 000000000..4cd6f8658 --- /dev/null +++ b/charts_flutter/lib/src/canvas/line_painter.dart @@ -0,0 +1,224 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; +import 'package:flutter/material.dart'; +import 'package:charts_common/common.dart' as common show Color; + +/// Draws a simple line. +/// +/// Lines may be styled with dash patterns similar to stroke-dasharray in SVG +/// path elements. Dash patterns are currently only supported between vertical +/// or horizontal line segments at this time. +class LinePainter { + /// Draws a simple line. + /// + /// [dashPattern] controls the pattern of dashes and gaps in a line. It is a + /// list of lengths of alternating dashes and gaps. The rendering is similar + /// to stroke-dasharray in SVG path elements. An odd number of values in the + /// pattern will be repeated to derive an even number of values. "1,2,3" is + /// equivalent to "1,2,3,1,2,3." + void draw( + {Canvas canvas, + Paint paint, + List points, + common.Color fill, + common.Color stroke, + bool roundEndCaps, + double strokeWidthPx, + List dashPattern}) { + if (points.isEmpty) { + return; + } + + // If the line has a single point and end caps, draw a line from +epsilon to + // -epsilon to draw a small circle for discontinuity support. Otherwise, + // draw nothing. + if (points.length == 1) { + if (roundEndCaps) { + final point = points.first; + points = [ + new Point(point.x - 0.0001, point.y), + new Point(point.x + 0.0001, point.y), + ]; + } else { + return; + } + } + + paint.color = new Color.fromARGB(stroke.a, stroke.r, stroke.g, stroke.b); + if (strokeWidthPx != null) { + paint.strokeWidth = strokeWidthPx; + } + paint.strokeJoin = StrokeJoin.bevel; + paint.style = PaintingStyle.stroke; + + if (dashPattern == null || dashPattern.isEmpty) { + _drawSolidLine(canvas, paint, points); + } else { + _drawDashedLine(canvas, paint, points, dashPattern); + } + } + + /// Draws solid lines between each point. + void _drawSolidLine(Canvas canvas, Paint paint, List points) { + // TODO: Extract a native line component which constructs the + // appropriate underlying data structures to avoid conversion. + final path = new Path() + ..moveTo(points.first.x.toDouble(), points.first.y.toDouble()); + + for (var point in points) { + path.lineTo(point.x.toDouble(), point.y.toDouble()); + } + + canvas.drawPath(path, paint); + } + + /// Draws dashed lines lines between each point. + void _drawDashedLine( + Canvas canvas, Paint paint, List points, List dashPattern) { + final localDashPattern = new List.from(dashPattern); + + // If an odd number of parts are defined, repeat the pattern to get an even + // number. + if (dashPattern.length % 2 == 1) { + localDashPattern.addAll(dashPattern); + } + + // Stores the previous point in the series. + var previousSeriesPoint = _getOffset(points.first); + + var remainder = 0; + var solid = true; + var dashPatternIndex = 0; + + // Gets the next segment in the dash pattern, looping back to the + // beginning once the end has been reached. + var getNextDashPatternSegment = () { + final dashSegment = localDashPattern[dashPatternIndex]; + dashPatternIndex = (dashPatternIndex + 1) % localDashPattern.length; + return dashSegment; + }; + + // Array of points that is used to draw a connecting path when only a + // partial dash pattern segment can be drawn in the remaining length of a + // line segment (between two defined points in the shape). + var remainderPoints; + + // Draw the path through all the rest of the points in the series. + for (var pointIndex = 1; pointIndex < points.length; pointIndex++) { + // Stores the current point in the series. + final seriesPoint = _getOffset(points[pointIndex]); + + if (previousSeriesPoint == seriesPoint) { + // Bypass dash pattern handling if the points are the same. + } else { + // Stores the previous point along the current series line segment where + // we rendered a dash (or left a gap). + var previousPoint = previousSeriesPoint; + + var d = _getOffsetDistance(previousSeriesPoint, seriesPoint); + + while (d > 0) { + var dashSegment = + remainder > 0 ? remainder : getNextDashPatternSegment(); + remainder = 0; + + // Create a unit vector in the direction from previous to next point. + final v = seriesPoint - previousPoint; + final u = new Offset(v.dx / v.distance, v.dy / v.distance); + + // If the remaining distance is less than the length of the dash + // pattern segment, then cut off the pattern segment for this portion + // of the overall line. + final distance = d < dashSegment ? d : dashSegment.toDouble(); + + // Compute a vector representing the length of dash pattern segment to + // be drawn. + final nextPoint = previousPoint + (u * distance); + + // If we are in a solid portion of the dash pattern, draw a line. + // Else, move on. + if (solid) { + if (remainderPoints != null) { + // If we had a partial un-drawn dash from the previous point along + // the line, draw a path that includes it and the end of the dash + // pattern segment in the current line segment. + remainderPoints.add(new Offset(nextPoint.dx, nextPoint.dy)); + + final path = new Path() + ..moveTo(remainderPoints.first.dx, remainderPoints.first.dy); + + for (var p in remainderPoints) { + path.lineTo(p.dx, p.dy); + } + + canvas.drawPath(path, paint); + + remainderPoints = null; + } else { + if (d < dashSegment && pointIndex < points.length - 1) { + // If the remaining distance d is too small to fit this dash, + // and we have more points in the line, save off a series of + // remainder points so that we can draw a path segment moving in + // the direction of the next point. + // + // Note that we don't need to save anything off for the "blank" + // portions of the pattern because we still take the remaining + // distance into account before starting the next dash in the + // next line segment. + remainderPoints = [ + new Offset(previousPoint.dx, previousPoint.dy), + new Offset(nextPoint.dx, nextPoint.dy) + ]; + } else { + // Otherwise, draw a simple line segment for this dash. + canvas.drawLine(previousPoint, nextPoint, paint); + } + } + } + + solid = !solid; + previousPoint = nextPoint; + d = d - dashSegment; + } + + // Save off the remaining distance so that we can continue the dash (or + // gap) into the next line segment. + remainder = -d.round(); + + // If we have a remaining un-drawn distance for the current dash (or + // gap), revert the last change to "solid" so that we will continue + // either drawing a dash or leaving a gap. + if (remainder > 0) { + solid = !solid; + } + } + + previousSeriesPoint = seriesPoint; + } + } + + /// Converts a [Point] into an [Offset]. + Offset _getOffset(Point point) => + new Offset(point.x.toDouble(), point.y.toDouble()); + + /// Computes the distance between two [Offset]s, as if they were [Point]s. + num _getOffsetDistance(Offset o1, Offset o2) { + final p1 = new Point(o1.dx, o1.dy); + final p2 = new Point(o2.dx, o2.dy); + return p1.distanceTo(p2); + } +} diff --git a/charts_flutter/lib/src/canvas/point_painter.dart b/charts_flutter/lib/src/canvas/point_painter.dart new file mode 100644 index 000000000..91db02a07 --- /dev/null +++ b/charts_flutter/lib/src/canvas/point_painter.dart @@ -0,0 +1,40 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point; +import 'package:flutter/material.dart'; +import 'package:charts_common/common.dart' as common show Color; + +/// Draws a simple point. +/// +/// TODO: Support for more shapes than circles? +class PointPainter { + void draw( + {Canvas canvas, + Paint paint, + Point point, + common.Color fill, + double radius}) { + if (point == null) { + return; + } + + paint.color = new Color.fromARGB(fill.a, fill.r, fill.g, fill.b); + paint.style = PaintingStyle.fill; + + canvas.drawCircle( + new Offset(point.x.toDouble(), point.y.toDouble()), radius, paint); + } +} diff --git a/charts_flutter/lib/src/cartesian_chart.dart b/charts_flutter/lib/src/cartesian_chart.dart new file mode 100644 index 000000000..66f3ea4cd --- /dev/null +++ b/charts_flutter/lib/src/cartesian_chart.dart @@ -0,0 +1,85 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +import 'package:charts_common/common.dart' as common + show + AxisSpec, + BaseChart, + CartesianChart, + RTLSpec, + Series, + SeriesRendererConfig; +import 'base_chart_state.dart' show BaseChartState; +import 'behaviors/chart_behavior.dart' show ChartBehavior; +import 'base_chart.dart' show BaseChart, LayoutConfig; +import 'selection_model_config.dart' show SelectionModelConfig; + +@immutable +abstract class CartesianChart extends BaseChart { + final common.AxisSpec domainAxis; + final common.AxisSpec primaryMeasureAxis; + final common.AxisSpec secondaryMeasureAxis; + + CartesianChart( + List seriesList, { + bool animate, + Duration animationDuration, + this.domainAxis, + this.primaryMeasureAxis, + this.secondaryMeasureAxis, + common.SeriesRendererConfig defaultRenderer, + List customSeriesRenderers, + List behaviors, + List selectionModels, + common.RTLSpec rtlSpec, + bool defaultInteractions: true, + LayoutConfig layoutConfig, + }) : super( + seriesList, + animate: animate, + animationDuration: animationDuration, + defaultRenderer: defaultRenderer, + customSeriesRenderers: customSeriesRenderers, + behaviors: behaviors, + selectionModels: selectionModels, + rtlSpec: rtlSpec, + defaultInteractions: defaultInteractions, + layoutConfig: layoutConfig, + ); + + void updateCommonChart(common.BaseChart baseChart, BaseChart oldWidget, + BaseChartState chartState) { + super.updateCommonChart(baseChart, oldWidget, chartState); + + final prev = oldWidget as CartesianChart; + final chart = baseChart as common.CartesianChart; + + if (domainAxis != null && domainAxis != prev?.domainAxis) { + chart.domainAxisSpec = domainAxis; + } + + if (primaryMeasureAxis != null && + primaryMeasureAxis != prev?.primaryMeasureAxis) { + chart.primaryMeasureAxisSpec = primaryMeasureAxis; + } + + if (secondaryMeasureAxis != null && + secondaryMeasureAxis != prev?.secondaryMeasureAxis) { + chart.secondaryMeasureAxisSpec = secondaryMeasureAxis; + } + } +} diff --git a/charts_flutter/lib/src/chart_canvas.dart b/charts_flutter/lib/src/chart_canvas.dart new file mode 100644 index 000000000..22ae60c3d --- /dev/null +++ b/charts_flutter/lib/src/chart_canvas.dart @@ -0,0 +1,270 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Point, Rectangle, max; +import 'package:charts_common/common.dart' as common + show + ChartCanvas, + CanvasBarStack, + Color, + FillPatternType, + StyleFactory, + TextElement, + TextDirection; +import 'package:flutter/material.dart'; +import 'text_element.dart' show TextElement; +import 'canvas/line_painter.dart' show LinePainter; +import 'canvas/point_painter.dart' show PointPainter; + +class ChartCanvas implements common.ChartCanvas { + final Canvas canvas; + final _paint = new Paint(); + + LinePainter _linePainter; + PointPainter _pointPainter; + + ChartCanvas(this.canvas); + + @override + void drawLine( + {List points, + common.Color fill, + common.Color stroke, + bool roundEndCaps, + double strokeWidthPx, + List dashPattern}) { + _linePainter ??= new LinePainter(); + _linePainter.draw( + canvas: canvas, + paint: _paint, + points: points, + fill: fill, + stroke: stroke, + roundEndCaps: roundEndCaps, + strokeWidthPx: strokeWidthPx, + dashPattern: dashPattern); + } + + @override + void drawPoint({Point point, common.Color fill, double radius}) { + _pointPainter ??= new PointPainter(); + _pointPainter.draw( + canvas: canvas, + paint: _paint, + point: point, + fill: fill, + radius: radius); + } + + @override + void drawRect(Rectangle bounds, + {common.Color fill, + common.FillPatternType pattern, + common.Color stroke}) { + switch (pattern) { + case common.FillPatternType.forwardHatch: + _drawForwardHatchPattern(bounds, canvas, fill: fill); + break; + + case common.FillPatternType.solid: + default: + // Use separate rect for drawing stroke + _paint.color = new Color.fromARGB(fill.a, fill.r, fill.g, fill.b); + _paint.style = PaintingStyle.fill; + + canvas.drawRect(_getRect(bounds), _paint); + break; + } + } + + @override + void drawRRect(Rectangle bounds, + {common.Color fill, + common.Color stroke, + num radius, + bool roundTopLeft, + bool roundTopRight, + bool roundBottomLeft, + bool roundBottomRight}) { + // Use separate rect for drawing stroke + _paint.color = new Color.fromARGB(fill.a, fill.r, fill.g, fill.b); + _paint.style = PaintingStyle.fill; + + canvas.drawRRect( + _getRRect(bounds, + radius: radius, + roundTopLeft: roundTopLeft, + roundTopRight: roundTopRight, + roundBottomLeft: roundBottomLeft, + roundBottomRight: roundBottomRight), + _paint); + } + + @override + void drawBarStack(common.CanvasBarStack barStack) { + // only clip if rounded rect. + + // Clip a rounded rect for the whole region if rounded bars. + final roundedCorners = 0 < barStack.radius; + + if (roundedCorners) { + canvas + ..save() + ..clipRRect(_getRRect( + barStack.fullStackRect, + radius: barStack.radius.toDouble(), + roundTopLeft: barStack.roundTopLeft, + roundTopRight: barStack.roundTopRight, + roundBottomLeft: barStack.roundBottomLeft, + roundBottomRight: barStack.roundBottomRight, + )); + } + + // Draw each bar. + for (var barIndex = 0; barIndex < barStack.segments.length; barIndex++) { + // TODO: Add configuration for hiding stack line. + final segment = barStack.segments[barIndex]; + drawRect(segment.bounds, + fill: segment.fill, pattern: segment.pattern, stroke: segment.stroke); + } + + if (roundedCorners) { + canvas.restore(); + } + } + + @override + void drawText(common.TextElement textElement, int offsetX, int offsetY) { + // Must be Flutter TextElement. + assert(textElement is TextElement); + + final flutterTextElement = textElement as TextElement; + final textDirection = flutterTextElement.textDirection; + final measurement = flutterTextElement.measurement; + + // TODO: Remove once textAnchor works. + if (textDirection == common.TextDirection.rtl) { + offsetX -= measurement.horizontalSliceWidth.toInt(); + } + + // Account for missing center alignment. + if (textDirection == common.TextDirection.center) { + offsetX -= (measurement.horizontalSliceWidth / 2).ceil(); + } + + offsetY -= flutterTextElement.verticalFontShift; + + (textElement as TextElement) + .textPainter + .paint(canvas, new Offset(offsetX.toDouble(), offsetY.toDouble())); + } + + /// Convert dart:math [Rectangle] to Flutter [Rect]. + Rect _getRect(Rectangle rectangle) { + return new Rect.fromLTWH( + rectangle.left.toDouble(), + rectangle.top.toDouble(), + rectangle.width.toDouble(), + rectangle.height.toDouble()); + } + + /// Convert dart:math [Rectangle] and to Flutter [RRect]. + RRect _getRRect( + Rectangle rectangle, { + double radius, + bool roundTopLeft = false, + bool roundTopRight = false, + bool roundBottomLeft = false, + bool roundBottomRight = false, + }) { + final cornerRadius = + radius == 0 ? Radius.zero : new Radius.circular(radius); + + return new RRect.fromLTRBAndCorners( + rectangle.left.toDouble(), + rectangle.top.toDouble(), + rectangle.right.toDouble(), + rectangle.bottom.toDouble(), + topLeft: roundTopLeft ? cornerRadius : Radius.zero, + topRight: roundTopRight ? cornerRadius : Radius.zero, + bottomLeft: roundBottomLeft ? cornerRadius : Radius.zero, + bottomRight: roundBottomRight ? cornerRadius : Radius.zero); + } + + /// Draws a forward hatch pattern in the given bounds. + _drawForwardHatchPattern( + Rectangle bounds, + Canvas canvas, { + common.Color background, + common.Color fill, + double fillWidthPx = 4.0, + }) { + background ??= common.StyleFactory.style.white; + fill ??= common.StyleFactory.style.black; + + // Fill in the shape with a solid background color. + _paint.color = new Color.fromARGB( + background.a, background.r, background.g, background.b); + _paint.style = PaintingStyle.fill; + canvas.drawRect(_getRect(bounds), _paint); + + // As a simplification, we will treat the bounds as a large square and fill + // it up with lines from the bottom-left corner to the top-right corner. + // Get the longer side of the bounds here for the size of this square. + final size = max(bounds.width, bounds.height); + + final x0 = bounds.left + size + fillWidthPx; + final x1 = bounds.left - fillWidthPx; + final y0 = bounds.bottom - size - fillWidthPx; + final y1 = bounds.bottom + fillWidthPx; + final offset = 8; + + final isVertical = bounds.height >= bounds.width; + + _linePainter ??= new LinePainter(); + + // The "first" line segment will be drawn from the bottom left corner of the + // bounds, up and towards the right. Start the loop N iterations "back" to + // draw partial line segments beneath (or to the left) of this segment, + // where N is the number of offsets that fit inside the smaller dimension of + // the bounds. + final smallSide = isVertical ? bounds.width : bounds.height; + final start = -(smallSide / offset).round() * offset; + + // Keep going until we reach the top or right of the bounds, depending on + // whether the rectangle is oriented vertically or horizontally. + final end = size + offset; + + for (int i = start; i < end; i = i + offset) { + // For vertical bounds, we need to draw lines from top to bottom. For + // bounds, we need to draw lines from left to right. + final modifier = isVertical ? -1 * i : i; + + // Draw a line segment in the bottom right corner of the pattern. + _linePainter.draw( + canvas: canvas, + paint: _paint, + points: [ + new Point(x0 + modifier, y0), + new Point(x1 + modifier, y1), + ], + stroke: fill, + strokeWidthPx: fillWidthPx); + } + } + + @override + set drawingView(String viewName) {} +} diff --git a/charts_flutter/lib/src/chart_container.dart b/charts_flutter/lib/src/chart_container.dart new file mode 100644 index 000000000..061cfa5ed --- /dev/null +++ b/charts_flutter/lib/src/chart_container.dart @@ -0,0 +1,320 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show + A11yNode, + BaseChart, + ChartContext, + DateTimeFactory, + LocalDateTimeFactory, + ProxyGestureListener, + RTLSpec, + Series, + Performance; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:meta/meta.dart' show required; +import 'chart_canvas.dart' show ChartCanvas; +import 'chart_state.dart' show ChartState; +import 'base_chart.dart' show BaseChart; +import 'graphics_factory.dart' show GraphicsFactory; +import 'time_series_chart.dart' show TimeSeriesChart; + +/// Widget that inflates to a [CustomPaint] that implements common [ChartContext]. +class ChartContainer extends CustomPaint { + final BaseChart chartWidget; + final BaseChart oldChartWidget; + final ChartState chartState; + final double animationValue; + final bool rtl; + final common.RTLSpec rtlSpec; + + ChartContainer( + {@required this.oldChartWidget, + @required this.chartWidget, + @required this.chartState, + @required this.animationValue, + @required this.rtl, + @required this.rtlSpec}); + + @override + RenderCustomPaint createRenderObject(BuildContext context) { + return new ChartContainerRenderObject()..reconfigure(this); + } + + @override + void updateRenderObject( + BuildContext context, ChartContainerRenderObject renderObject) { + renderObject.reconfigure(this); + } +} + +/// [RenderCustomPaint] that implements common [ChartContext]. +class ChartContainerRenderObject extends RenderCustomPaint + implements common.ChartContext { + common.BaseChart _chart; + List> _seriesList; + ChartState _chartState; + bool _rtl; + common.RTLSpec _rtlSpec; + common.DateTimeFactory _dateTimeFactory; + bool _exploreMode = false; + List _a11yNodes; + + void reconfigure(ChartContainer config) { + _chartState = config.chartState; + + _dateTimeFactory = (config.chartWidget is TimeSeriesChart) + ? (config.chartWidget as TimeSeriesChart).dateTimeFactory + : null; + _dateTimeFactory ??= new common.LocalDateTimeFactory(); + + if (_chart == null) { + common.Performance.time('chartsCreate'); + _chart = config.chartWidget.createCommonChart(_chartState); + _chart.init(this, const GraphicsFactory()); + common.Performance.timeEnd('chartsCreate'); + } + common.Performance.time('chartsConfig'); + config.chartWidget + .updateCommonChart(_chart, config.oldChartWidget, _chartState); + + _rtl = config.rtl; + _rtlSpec = config.rtlSpec ?? const common.RTLSpec(); + common.Performance.timeEnd('chartsConfig'); + + // if series list changes, axis change, behavior, etc + // current check based on checking if it's the same instance + if (_seriesList != config.chartWidget.seriesList) { + _seriesList = config.chartWidget.seriesList; + + // Clear out the a11y nodes generated. + _a11yNodes = null; + + common.Performance.time('chartsDraw'); + _chart.draw(_seriesList); + common.Performance.timeEnd('chartsDraw'); + + // This is needed because when a series changes we need to reset flutter's + // animation value from 1.0 back to 0.0. + _chart.animationPercent = 0.0; + markNeedsLayout(); + } else { + _chart.animationPercent = config.animationValue; + markNeedsPaint(); + } + + // Set the painter used for calling common chart for paint. + // This painter is also used to generate semantic nodes for a11y. + _setNewPainter(); + } + + @override + void performLayout() { + common.Performance.time('chartsLayout'); + _chart.measure(constraints.maxWidth.toInt(), constraints.maxHeight.toInt()); + _chart.layout(constraints.maxWidth.toInt(), constraints.maxHeight.toInt()); + common.Performance.timeEnd('chartsLayout'); + size = constraints.biggest; + + // Check if the gestures registered in gesture registry matches what the + // common chart is listening to. + // TODO: Still need a test for this for sanity sake. +// assert(_desiredGestures +// .difference(_chart.gestureProxy.listenedGestures) +// .isEmpty); + } + + @override + void markNeedsLayout() { + super.markNeedsLayout(); + if (parent != null) { + markParentNeedsLayout(); + } + } + + @override + bool hitTestSelf(Offset position) => true; + + @override + void requestRedraw() {} + + @override + void requestAnimation(Duration transition) { + void startAnimationController(_) { + _chartState.setAnimation(transition); + } + + SchedulerBinding.instance.addPostFrameCallback(startAnimationController); + } + + /// Request Flutter to rebuild the widget/container of chart. + /// + /// This is different than requesting redraw and paint because those only + /// affect the chart widget. This is for requesting rebuild of the Flutter + /// widget that contains the chart widget. This is necessary for supporting + /// Flutter widgets that are layout with the chart. + /// + /// Example is legends, a legend widget can be layout on top of the chart + /// widget or along the sides of the chart. Requesting a rebuild allows + /// the legend to layout and redraw itself. + void requestRebuild() { + void doRebuild(_) { + _chartState.requestRebuild(); + } + + // Flutter does not allow requesting rebuild during the build cycle, this + // schedules rebuild request to happen after the current build cycle. + // This is needed to request rebuild after the legend has been added in the + // post process phase of the chart, which happens during the chart widget's + // build cycle. + SchedulerBinding.instance.addPostFrameCallback(doRebuild); + } + + /// When Flutter's markNeedsLayout is called, layout and paint are both + /// called. If animations are off, Flutter's paint call after layout will + /// paint the chart. If animations are on, Flutter's paint is called with the + /// initial animation value and then the animation controller is started after + /// this first build cycle. + @override + void requestPaint() { + markNeedsPaint(); + } + + @override + double get pixelsPerDp => 1.0; + + @override + bool get rtl => _rtl; + + @override + common.RTLSpec get rtlSpec => _rtlSpec; + + @override + common.DateTimeFactory get dateTimeFactory => _dateTimeFactory; + + /// Gets the chart's gesture listener. + common.ProxyGestureListener get gestureProxy => _chart.gestureProxy; + + TextDirection get textDirection => + rtl ? TextDirection.rtl : TextDirection.ltr; + + @override + void enableA11yExploreMode(List nodes, + {String announcement}) { + _a11yNodes = nodes; + _exploreMode = true; + _setNewPainter(); + requestRebuild(); + if (announcement != null) { + SemanticsService.announce(announcement, textDirection); + } + } + + @override + void disableA11yExploreMode({String announcement}) { + _a11yNodes = []; + _exploreMode = false; + _setNewPainter(); + requestRebuild(); + if (announcement != null) { + SemanticsService.announce(announcement, textDirection); + } + } + + void _setNewPainter() { + painter = new ChartContainerCustomPaint( + oldPainter: painter, + chart: _chart, + exploreMode: _exploreMode, + a11yNodes: _a11yNodes, + textDirection: textDirection); + } +} + +class ChartContainerCustomPaint extends CustomPainter { + final common.BaseChart chart; + final bool exploreMode; + final List a11yNodes; + final TextDirection textDirection; + + factory ChartContainerCustomPaint( + {ChartContainerCustomPaint oldPainter, + common.BaseChart chart, + bool exploreMode, + List a11yNodes, + TextDirection textDirection}) { + if (oldPainter != null && + oldPainter.exploreMode == exploreMode && + oldPainter.a11yNodes == a11yNodes && + oldPainter.textDirection == textDirection) { + return oldPainter; + } else { + return new ChartContainerCustomPaint._internal( + chart: chart, + exploreMode: exploreMode ?? false, + a11yNodes: a11yNodes ?? [], + textDirection: textDirection ?? TextDirection.ltr); + } + } + + ChartContainerCustomPaint._internal( + {this.chart, this.exploreMode, this.a11yNodes, this.textDirection}); + + @override + void paint(Canvas canvas, Size size) { + common.Performance.time('chartsPaint'); + final chartsCanvas = new ChartCanvas(canvas); + chart.paint(chartsCanvas); + common.Performance.timeEnd('chartsPaint'); + } + + /// Common chart requests rebuild that handle repaint requests. + @override + bool shouldRepaint(ChartContainerCustomPaint oldPainter) => false; + + /// Rebuild semantics when explore mode is toggled semantic properties change. + @override + bool shouldRebuildSemantics(ChartContainerCustomPaint oldDelegate) { + return exploreMode != oldDelegate.exploreMode || + a11yNodes != oldDelegate.a11yNodes || + textDirection != textDirection; + } + + @override + SemanticsBuilderCallback get semanticsBuilder => _buildSemantics; + + List _buildSemantics(Size size) { + final nodes = []; + + for (common.A11yNode node in a11yNodes) { + final rect = new Rect.fromLTWH( + node.boundingBox.left.toDouble(), + node.boundingBox.top.toDouble(), + node.boundingBox.width.toDouble(), + node.boundingBox.height.toDouble()); + nodes.add(new CustomPainterSemantics( + rect: rect, + properties: new SemanticsProperties( + value: node.label, + textDirection: textDirection, + onDidGainAccessibilityFocus: node.onFocus))); + } + + return nodes; + } +} diff --git a/charts_flutter/lib/src/chart_gesture_detector.dart b/charts_flutter/lib/src/chart_gesture_detector.dart new file mode 100644 index 000000000..8c8249ea4 --- /dev/null +++ b/charts_flutter/lib/src/chart_gesture_detector.dart @@ -0,0 +1,136 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:async' show Timer; +import 'dart:math' show Point; +import 'package:flutter/material.dart' + show + BuildContext, + GestureDetector, + ScaleEndDetails, + ScaleStartDetails, + ScaleUpdateDetails, + TapDownDetails, + TapUpDetails; + +import 'behaviors/chart_behavior.dart' show GestureType; +import 'chart_container.dart' show ChartContainer, ChartContainerRenderObject; +import 'util.dart' show getChartContainerRenderObject; + +// From https://docs.flutter.io/flutter/gestures/kLongPressTimeout-constant.html +const Duration _kLongPressTimeout = const Duration(milliseconds: 500); + +class ChartGestureDetector { + bool _listeningForLongPress; + + bool _isDragging = false; + + Timer _longPressTimer; + Point _lastTapPoint; + double _lastScale; + + _ContainerResolver _containerResolver; + + makeWidget(BuildContext context, ChartContainer chartContainer, + Set desiredGestures) { + _containerResolver = + () => getChartContainerRenderObject(context.findRenderObject()); + + final wantTapDown = desiredGestures.isNotEmpty; + final wantTap = desiredGestures.contains(GestureType.onTap); + final wantDrag = desiredGestures.contains(GestureType.onDrag); + + // LongPress is special, we'd like to be able to trigger long press before + // Drag/Press to trigger tooltips then explore with them. This means we + // can't rely on gesture detection since it will block out the scale + // gestures. + _listeningForLongPress = desiredGestures.contains(GestureType.onLongPress); + + return new GestureDetector( + child: chartContainer, + onTapDown: wantTapDown ? onTapDown : null, + onTapUp: wantTap ? onTapUp : null, + onScaleStart: wantDrag ? onScaleStart : null, + onScaleUpdate: wantDrag ? onScaleUpdate : null, + onScaleEnd: wantDrag ? onScaleEnd : null, + ); + } + + void onTapDown(TapDownDetails d) { + final container = _containerResolver(); + final localPosition = container.globalToLocal(d.globalPosition); + _lastTapPoint = new Point(localPosition.dx, localPosition.dy); + container.gestureProxy.onTapTest(_lastTapPoint); + + // Kick off a timer to see if this is a LongPress. + if (_listeningForLongPress) { + _longPressTimer = new Timer(_kLongPressTimeout, () { + onLongPress(); + _longPressTimer = null; + }); + } + } + + void onTapUp(TapUpDetails d) { + _longPressTimer?.cancel(); + + final container = _containerResolver(); + final localPosition = container.globalToLocal(d.globalPosition); + _lastTapPoint = new Point(localPosition.dx, localPosition.dy); + container.gestureProxy.onTap(_lastTapPoint); + } + + void onLongPress() { + final container = _containerResolver(); + container.gestureProxy.onLongPress(_lastTapPoint); + } + + void onScaleStart(ScaleStartDetails d) { + _longPressTimer?.cancel(); + + final container = _containerResolver(); + final localPosition = container.globalToLocal(d.focalPoint); + _lastTapPoint = new Point(localPosition.dx, localPosition.dy); + + _isDragging = container.gestureProxy.onDragStart(_lastTapPoint); + } + + void onScaleUpdate(ScaleUpdateDetails d) { + if (!_isDragging) { + return; + } + + final container = _containerResolver(); + final localPosition = container.globalToLocal(d.focalPoint); + _lastTapPoint = new Point(localPosition.dx, localPosition.dy); + _lastScale = d.scale; + + container.gestureProxy.onDragUpdate(_lastTapPoint, d.scale); + } + + void onScaleEnd(ScaleEndDetails d) { + if (!_isDragging) { + return; + } + + final container = _containerResolver(); + + container.gestureProxy + .onDragEnd(_lastTapPoint, _lastScale, d.velocity.pixelsPerSecond.dx); + } +} + +// Exposed for testing. +typedef ChartContainerRenderObject _ContainerResolver(); diff --git a/charts_flutter/lib/src/chart_state.dart b/charts_flutter/lib/src/chart_state.dart new file mode 100644 index 000000000..8dc73ae0c --- /dev/null +++ b/charts_flutter/lib/src/chart_state.dart @@ -0,0 +1,21 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +abstract class ChartState { + void setAnimation(Duration transition); + + /// Request to the native platform to rebuild the chart. + void requestRebuild(); +} diff --git a/charts_flutter/lib/src/graphics_factory.dart b/charts_flutter/lib/src/graphics_factory.dart new file mode 100644 index 000000000..77d66d16b --- /dev/null +++ b/charts_flutter/lib/src/graphics_factory.dart @@ -0,0 +1,37 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show GraphicsFactory, LineStyle, TextElement, TextStyle; +import 'line_style.dart' show LineStyle; +import 'text_element.dart' show TextElement; +import 'text_style.dart' show TextStyle; + +class GraphicsFactory implements common.GraphicsFactory { + const GraphicsFactory(); + + /// Returns a [TextStyle] object. + @override + common.TextStyle createTextPaint() => new TextStyle(); + + /// Returns a text element from [text] and [style]. + @override + common.TextElement createTextElement(String text) { + return new TextElement(text); + } + + @override + common.LineStyle createLinePaint() => new LineStyle(); +} diff --git a/charts_flutter/lib/src/line_chart.dart b/charts_flutter/lib/src/line_chart.dart new file mode 100644 index 000000000..4da7e467e --- /dev/null +++ b/charts_flutter/lib/src/line_chart.dart @@ -0,0 +1,57 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show LineChart, BaseChart, RTLSpec, Series, LineRendererConfig; +import 'behaviors/line_point_highlighter.dart' show LinePointHighlighter; +import 'behaviors/chart_behavior.dart' show ChartBehavior; +import 'base_chart.dart' show BaseChart, LayoutConfig; +import 'base_chart_state.dart' show BaseChartState; +import 'selection_model_config.dart' show SelectionModelConfig; + +class LineChart extends BaseChart { + LineChart( + List seriesList, { + bool animate, + Duration animationDuration, + common.LineRendererConfig defaultRenderer, + List behaviors, + List selectionModels, + common.RTLSpec rtlSpec, + LayoutConfig layoutConfig, + bool defaultInteractions: true, + }) : super( + seriesList, + animate: animate, + animationDuration: animationDuration, + defaultRenderer: defaultRenderer, + behaviors: behaviors, + selectionModels: selectionModels, + rtlSpec: rtlSpec, + layoutConfig: layoutConfig, + defaultInteractions: defaultInteractions, + ); + + @override + common.BaseChart createCommonChart(BaseChartState chartState) => + new common.LineChart(layoutConfig: layoutConfig?.commonLayoutConfig); + + @override + void addDefaultInteractions(List behaviors) { + super.addDefaultInteractions(behaviors); + + behaviors.add(new LinePointHighlighter()); + } +} diff --git a/charts_flutter/lib/src/line_style.dart b/charts_flutter/lib/src/line_style.dart new file mode 100644 index 000000000..d8706bd47 --- /dev/null +++ b/charts_flutter/lib/src/line_style.dart @@ -0,0 +1,23 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common show Color, LineStyle; + +class LineStyle implements common.LineStyle { + @override + common.Color color; + @override + int strokeWidth; +} diff --git a/charts_flutter/lib/src/selection_model_config.dart b/charts_flutter/lib/src/selection_model_config.dart new file mode 100644 index 000000000..68aba483e --- /dev/null +++ b/charts_flutter/lib/src/selection_model_config.dart @@ -0,0 +1,27 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:meta/meta.dart' show immutable; + +import 'package:charts_common/common.dart' as common; + +@immutable +class SelectionModelConfig { + final common.SelectionModelType type; + final common.SelectionModelListener listener; + + SelectionModelConfig( + {this.type = common.SelectionModelType.info, this.listener}); +} diff --git a/charts_flutter/lib/src/symbol_renderer.dart b/charts_flutter/lib/src/symbol_renderer.dart new file mode 100644 index 000000000..50c38e7a2 --- /dev/null +++ b/charts_flutter/lib/src/symbol_renderer.dart @@ -0,0 +1,77 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:charts_common/common.dart' as common + show ChartCanvas, Color, SymbolRenderer, RoundedRectSymbolRenderer; +import 'package:flutter/widgets.dart'; +import 'chart_canvas.dart' show ChartCanvas; + +// TODO: Add line symbol renderer for line charts. + +/// Strategy for rendering a symbol. +abstract class SymbolRenderer implements common.SymbolRenderer { + /// Used by Charts Flutter library implementation only. + /// + /// Used by SymbolRenderers that wrap a common symbol renderer. + @override + void paint( + common.ChartCanvas canvas, Rectangle bounds, common.Color color) {} + + /// Used by Charts Flutter library implementation only. + /// + /// Used by SymbolRenderers that wrap a common symbol renderer. + @override + bool shouldRepaint(covariant common.SymbolRenderer oldRenderer) => false; + + /// Build a symbol widget. + /// + /// [size] suggested size of the symbol provided by [LegendEntryLayout]. + /// [color] color of the legend entry. + Widget build(BuildContext context, {Size size, Color color}); +} + +class RoundedRectSymbolRenderer extends common.RoundedRectSymbolRenderer + implements SymbolRenderer { + RoundedRectSymbolRenderer({double radius}) : super(radius: radius); + + @override + Widget build(BuildContext context, {Size size, Color color}) { + return new SizedBox.fromSize( + size: size, + child: new CustomPaint(painter: new _SymbolCustomPaint(this, color))); + } +} + +class _SymbolCustomPaint extends CustomPainter { + final common.SymbolRenderer symbolRenderer; + final Color color; + + _SymbolCustomPaint(this.symbolRenderer, this.color); + + @override + void paint(Canvas canvas, Size size) { + final bounds = + new Rectangle(0, 0, size.width.toInt(), size.height.toInt()); + final commonColor = new common.Color( + r: color.red, g: color.green, b: color.blue, a: color.alpha); + symbolRenderer.paint(new ChartCanvas(canvas), bounds, commonColor); + } + + @override + bool shouldRepaint(_SymbolCustomPaint oldDelegate) { + return symbolRenderer.shouldRepaint(oldDelegate.symbolRenderer); + } +} diff --git a/charts_flutter/lib/src/text_element.dart b/charts_flutter/lib/src/text_element.dart new file mode 100644 index 000000000..62d3311c0 --- /dev/null +++ b/charts_flutter/lib/src/text_element.dart @@ -0,0 +1,160 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:ui' show TextAlign, TextDirection; +import 'package:charts_common/common.dart' as common + show + MaxWidthStrategy, + TextElement, + TextDirection, + TextMeasurement, + TextStyle; +import 'package:flutter/rendering.dart' + show Color, TextBaseline, TextPainter, TextSpan, TextStyle; + +/// Flutter implementation for text measurement and painter. +class TextElement implements common.TextElement { + static const ellipsis = '\u{2026}'; + + @override + final String text; + + var _painterReady = false; + common.TextStyle _textStyle; + common.TextDirection _textDirection = common.TextDirection.ltr; + + int _maxWidth; + common.MaxWidthStrategy _maxWidthStrategy; + + TextPainter _textPainter; + + common.TextMeasurement _measurement; + + TextElement(this.text, {common.TextStyle style}) : _textStyle = style; + + @override + common.TextStyle get textStyle => _textStyle; + + @override + set textStyle(common.TextStyle value) { + if (_textStyle == value) { + return; + } + _textStyle = value; + _painterReady = false; + } + + @override + set textDirection(common.TextDirection direction) { + if (_textDirection == direction) { + return; + } + _textDirection = direction; + _painterReady = false; + } + + @override + common.TextDirection get textDirection => _textDirection; + + @override + int get maxWidth => _maxWidth; + + @override + set maxWidth(int value) { + if (_maxWidth == value) { + return; + } + _maxWidth = value; + _painterReady = false; + } + + @override + common.MaxWidthStrategy get maxWidthStrategy => _maxWidthStrategy; + + @override + set maxWidthStrategy(common.MaxWidthStrategy maxWidthStrategy) { + if (_maxWidthStrategy == maxWidthStrategy) { + return; + } + _maxWidthStrategy = maxWidthStrategy; + _painterReady = false; + } + + @override + common.TextMeasurement get measurement { + if (!_painterReady) { + _refreshPainter(); + } + + return _measurement; + } + + /// The estimated distance between where we asked to draw the text (top, left) + /// and where it visually started (top + verticalFontShift, left). + /// + /// 10% of reported font height seems to be about right. + int get verticalFontShift { + if (!_painterReady) { + _refreshPainter(); + } + + return (_textPainter.height * 0.1).ceil(); + } + + TextPainter get textPainter { + if (!_painterReady) { + _refreshPainter(); + } + return _textPainter; + } + + /// Create text painter and measure based on current settings + void _refreshPainter() { + final color = new Color.fromARGB(textStyle.color.a, textStyle.color.r, + textStyle.color.g, textStyle.color.b); + + _textPainter = new TextPainter( + text: new TextSpan( + text: text, + style: new TextStyle( + color: color, + fontSize: textStyle.fontSize.toDouble(), + fontFamily: textStyle.fontFamily))) + ..textDirection = TextDirection.ltr + // TODO Flip once textAlign works + ..textAlign = TextAlign.left + // ..textAlign = _textDirection == common.TextDirection.rtl ? + // TextAlign.right : TextAlign.left + ..ellipsis = maxWidthStrategy == common.MaxWidthStrategy.ellipsize + ? ellipsis + : null + ..layout(maxWidth: maxWidth?.toDouble() ?? double.INFINITY); + + final baseline = + _textPainter.computeDistanceToActualBaseline(TextBaseline.alphabetic); + + // Estimating the actual draw height to 70% of measures size. + // + // The font reports a size larger than the drawn size, which makes it + // difficult to shift the text around to get it to visually line up + // vertically with other components. + _measurement = new common.TextMeasurement( + horizontalSliceWidth: _textPainter.width, + verticalSliceWidth: _textPainter.height * 0.70, + baseline: baseline); + + _painterReady = true; + } +} diff --git a/charts_flutter/lib/src/text_style.dart b/charts_flutter/lib/src/text_style.dart new file mode 100644 index 000000000..0b2bf5d0a --- /dev/null +++ b/charts_flutter/lib/src/text_style.dart @@ -0,0 +1,22 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common show Color, TextStyle; + +class TextStyle implements common.TextStyle { + int fontSize; + String fontFamily; + common.Color color; +} diff --git a/charts_flutter/lib/src/time_series_chart.dart b/charts_flutter/lib/src/time_series_chart.dart new file mode 100644 index 000000000..d5045f169 --- /dev/null +++ b/charts_flutter/lib/src/time_series_chart.dart @@ -0,0 +1,76 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:charts_common/common.dart' as common + show + AxisSpec, + BaseChart, + DateTimeFactory, + LineRendererConfig, + Series, + TimeSeriesChart; +import 'behaviors/chart_behavior.dart' show ChartBehavior; +import 'behaviors/line_point_highlighter.dart' show LinePointHighlighter; +import 'cartesian_chart.dart' show CartesianChart; +import 'base_chart.dart' show LayoutConfig; +import 'base_chart_state.dart' show BaseChartState; +import 'selection_model_config.dart' show SelectionModelConfig; + +class TimeSeriesChart extends CartesianChart { + final common.DateTimeFactory dateTimeFactory; + + /// Create a [TimeSeriesChart]. + /// + /// [dateTimeFactory] allows specifying a factory that creates [DateTime] to + /// be used for the time axis. If none specified, local date time is used. + TimeSeriesChart( + List seriesList, { + bool animate, + Duration animationDuration, + common.AxisSpec domainAxis, + common.AxisSpec primaryMeasureAxis, + common.AxisSpec secondaryMeasureAxis, + common.LineRendererConfig defaultRenderer, + List behaviors, + List selectionModels, + LayoutConfig layoutConfig, + this.dateTimeFactory, + bool defaultInteractions: true, + }) : super( + seriesList, + animate: animate, + animationDuration: animationDuration, + domainAxis: domainAxis, + primaryMeasureAxis: primaryMeasureAxis, + secondaryMeasureAxis: secondaryMeasureAxis, + defaultRenderer: defaultRenderer, + behaviors: behaviors, + selectionModels: selectionModels, + layoutConfig: layoutConfig, + defaultInteractions: defaultInteractions, + ); + + @override + common.BaseChart createCommonChart(BaseChartState chartState) => + new common.TimeSeriesChart( + layoutConfig: layoutConfig?.commonLayoutConfig); + + @override + void addDefaultInteractions(List behaviors) { + super.addDefaultInteractions(behaviors); + + behaviors.add(new LinePointHighlighter()); + } +} diff --git a/charts_flutter/lib/src/util.dart b/charts_flutter/lib/src/util.dart new file mode 100644 index 000000000..b39174825 --- /dev/null +++ b/charts_flutter/lib/src/util.dart @@ -0,0 +1,45 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/rendering.dart' + show + RenderBox, + RenderSemanticsGestureHandler, + RenderPointerListener, + RenderCustomMultiChildLayoutBox; +import 'chart_container.dart' show ChartContainerRenderObject; + +/// Get the [ChartContainerRenderObject] from a [RenderBox]. +/// +/// [RenderBox] is expected to be a [RenderSemanticsGestureHandler] with child +/// of [RenderPointerListener] with child of [ChartContainerRenderObject]. +ChartContainerRenderObject getChartContainerRenderObject(RenderBox box) { + assert(box is RenderCustomMultiChildLayoutBox); + final semanticHandler = (box as RenderCustomMultiChildLayoutBox) + .getChildrenAsList() + .firstWhere((child) => child is RenderSemanticsGestureHandler); + + assert(semanticHandler is RenderSemanticsGestureHandler); + final renderPointerListener = + (semanticHandler as RenderSemanticsGestureHandler).child; + + assert(renderPointerListener is RenderPointerListener); + final chartContainerRenderObject = + (renderPointerListener as RenderPointerListener).child; + + assert(chartContainerRenderObject is ChartContainerRenderObject); + + return chartContainerRenderObject as ChartContainerRenderObject; +} diff --git a/charts_flutter/lib/src/widget_layout_delegate.dart b/charts_flutter/lib/src/widget_layout_delegate.dart new file mode 100644 index 000000000..4eac34228 --- /dev/null +++ b/charts_flutter/lib/src/widget_layout_delegate.dart @@ -0,0 +1,216 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:ui' show Offset; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; +import 'behaviors/chart_behavior.dart' + show + BuildableBehavior, + BuildablePosition, + InsideJustification, + OutsideJustification; + +/// Layout delegate that layout chart widget with [BuildableBehavior] widgets. +class WidgetLayoutDelegate extends MultiChildLayoutDelegate { + /// ID of the common chart widget. + final String chartID; + + /// Directionality of the widget. + final isRTL; + + /// ID and [BuildableBehavior] of the widgets for calculating offset. + final Map idAndBehavior; + + WidgetLayoutDelegate(this.chartID, this.idAndBehavior, this.isRTL); + + @override + void performLayout(Size size) { + // TODO: Change this to a layout manager that supports more + // than one buildable behavior that changes chart size. Remove assert when + // this is possible. + assert(idAndBehavior.keys.isEmpty || idAndBehavior.keys.length == 1); + + // Size available for the chart widget. + var availableWidth = size.width; + var availableHeight = size.height; + var chartOffset = Offset.zero; + + // Measure the first buildable behavior. + final behaviorID = + idAndBehavior.keys.isNotEmpty ? idAndBehavior.keys.first : null; + var behaviorSize = Size.zero; + if (behaviorID != null) { + if (hasChild(behaviorID)) { + final leftPosition = + isRTL ? BuildablePosition.end : BuildablePosition.start; + final rightPosition = + isRTL ? BuildablePosition.start : BuildablePosition.end; + final behaviorPosition = idAndBehavior[behaviorID].position; + + behaviorSize = layoutChild(behaviorID, new BoxConstraints.loose(size)); + if (behaviorPosition == BuildablePosition.top) { + chartOffset = new Offset(0.0, behaviorSize.height); + availableHeight -= behaviorSize.height; + } else if (behaviorPosition == BuildablePosition.bottom) { + availableHeight -= behaviorSize.height; + } else if (behaviorPosition == leftPosition) { + chartOffset = new Offset(behaviorSize.width, 0.0); + availableWidth -= behaviorSize.width; + } else if (behaviorPosition == rightPosition) { + availableWidth -= behaviorSize.width; + } + } + } + + // Layout chart. + final chartSize = new Size(availableWidth, availableHeight); + if (hasChild(chartID)) { + layoutChild(chartID, new BoxConstraints.tight(chartSize)); + positionChild(chartID, chartOffset); + } + + // Position buildable behavior. + if (behaviorID != null) { + // TODO: Unable to relayout with new smaller width. + // In the delegate, all children are required to have layout called + // exactly once. + final behaviorOffset = _getBehaviorOffset(idAndBehavior[behaviorID], + behaviorSize: behaviorSize, chartSize: chartSize, isRTL: isRTL); + + positionChild(behaviorID, behaviorOffset); + } + } + + @override + bool shouldRelayout(MultiChildLayoutDelegate oldDelegate) { + // TODO: Deep equality check because the instance will not be + // the same on each build, even if the buildable behavior has not changed. + return idAndBehavior != (oldDelegate as WidgetLayoutDelegate).idAndBehavior; + } + + // Calculate buildable behavior's offset. + Offset _getBehaviorOffset(BuildableBehavior behavior, + {Size behaviorSize, Size chartSize, bool isRTL}) { + Offset behaviorOffset; + + final behaviorPosition = behavior.position; + final outsideJustification = behavior.outsideJustification; + final insideJustification = behavior.insideJustification; + + if (behaviorPosition == BuildablePosition.top || + behaviorPosition == BuildablePosition.bottom) { + final heightOffset = + behaviorPosition == BuildablePosition.bottom ? chartSize.height : 0.0; + + final horizontalJustification = + getOutsideJustification(outsideJustification, isRTL); + + switch (horizontalJustification) { + case _HorizontalJustification.leftDrawArea: + behaviorOffset = + new Offset(behavior.drawAreaBounds.left.toDouble(), heightOffset); + break; + case _HorizontalJustification.left: + behaviorOffset = new Offset(0.0, heightOffset); + break; + case _HorizontalJustification.rightDrawArea: + behaviorOffset = new Offset( + behavior.drawAreaBounds.right - behaviorSize.width, heightOffset); + break; + case _HorizontalJustification.right: + behaviorOffset = + new Offset(chartSize.width - behaviorSize.width, heightOffset); + break; + } + } else if (behaviorPosition == BuildablePosition.start || + behaviorPosition == BuildablePosition.end) { + final widthOffset = + (isRTL && behaviorPosition == BuildablePosition.start) || + (!isRTL && behaviorPosition == BuildablePosition.end) + ? chartSize.width + : 0.0; + + switch (outsideJustification) { + case OutsideJustification.startDrawArea: + behaviorOffset = + new Offset(widthOffset, behavior.drawAreaBounds.top.toDouble()); + break; + case OutsideJustification.start: + behaviorOffset = new Offset(widthOffset, 0.0); + break; + case OutsideJustification.endDrawArea: + behaviorOffset = new Offset(widthOffset, + behavior.drawAreaBounds.bottom - behaviorSize.height); + break; + case OutsideJustification.end: + behaviorOffset = + new Offset(widthOffset, chartSize.height - behaviorSize.height); + break; + } + } else if (behaviorPosition == BuildablePosition.inside) { + var rightOffset = new Offset(chartSize.width - behaviorSize.width, 0.0); + + switch (insideJustification) { + case InsideJustification.topStart: + behaviorOffset = isRTL ? rightOffset : Offset.zero; + break; + case InsideJustification.topEnd: + behaviorOffset = isRTL ? Offset.zero : rightOffset; + break; + } + } + + return behaviorOffset; + } + + _HorizontalJustification getOutsideJustification( + OutsideJustification justification, bool isRTL) { + _HorizontalJustification mappedJustification; + + switch (justification) { + case OutsideJustification.startDrawArea: + mappedJustification = isRTL + ? _HorizontalJustification.rightDrawArea + : _HorizontalJustification.leftDrawArea; + break; + case OutsideJustification.start: + mappedJustification = isRTL + ? _HorizontalJustification.right + : _HorizontalJustification.left; + break; + case OutsideJustification.endDrawArea: + mappedJustification = isRTL + ? _HorizontalJustification.leftDrawArea + : _HorizontalJustification.rightDrawArea; + break; + case OutsideJustification.end: + mappedJustification = isRTL + ? _HorizontalJustification.left + : _HorizontalJustification.right; + break; + } + + return mappedJustification; + } +} + +enum _HorizontalJustification { + leftDrawArea, + left, + rightDrawArea, + right, +} diff --git a/charts_flutter/pubspec.yaml b/charts_flutter/pubspec.yaml new file mode 100644 index 000000000..e5587ba53 --- /dev/null +++ b/charts_flutter/pubspec.yaml @@ -0,0 +1,22 @@ +name: charts_flutter +version: 0.0.1 +description: Material Design charting library for flutter. +author: Charts Team +homepage: https://github.com/google/charts + +environment: + sdk: '>=1.23.0 <2.0.0' + +dependencies: + charts_common: 0.0.1 + collection: ^1.14.5 + flutter: + sdk: flutter + meta: ^1.1.1 + + +dev_dependencies: + mockito: 3.0.0-alpha + flutter_test: + sdk: flutter + test: ^0.12.0 diff --git a/charts_flutter/test/behaviors/legend/legend_layout_test.dart b/charts_flutter/test/behaviors/legend/legend_layout_test.dart new file mode 100644 index 000000000..e858d8d26 --- /dev/null +++ b/charts_flutter/test/behaviors/legend/legend_layout_test.dart @@ -0,0 +1,116 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/material.dart'; +import 'package:mockito/mockito.dart'; +import 'package:test/test.dart'; + +import 'package:charts_flutter/src/behaviors/legend/legend_layout.dart'; + +class MockContext extends Mock implements BuildContext {} + +void main() { + BuildContext context; + + setUp(() { + context = new MockContext(); + }); + + group('TabularLegendLayoutBuilder', () { + test('builds horizontally', () { + final builder = new TabularLegendLayout.horizontalFirst(); + final widgets = [new Text('1'), new Text('2'), new Text('3')]; + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 1); + expect(layout.children.first.children.length, 3); + }); + + test('does not build extra columns if max columns exceed widget count', () { + final builder = + new TabularLegendLayout.horizontalFirst(desiredMaxColumns: 10); + final widgets = [new Text('1'), new Text('2'), new Text('3')]; + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 1); + expect(layout.children.first.children.length, 3); + }); + + test('builds horizontally until max column exceeded', () { + final builder = + new TabularLegendLayout.horizontalFirst(desiredMaxColumns: 2); + + final widgets = new List.generate( + 7, (int index) => new Text(index.toString())); + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 4); + + expect(layout.children[0].children[0], equals(widgets[0])); + expect(layout.children[0].children[1], equals(widgets[1])); + + expect(layout.children[1].children[0], equals(widgets[2])); + expect(layout.children[1].children[1], equals(widgets[3])); + + expect(layout.children[2].children[0], equals(widgets[4])); + expect(layout.children[2].children[1], equals(widgets[5])); + + expect(layout.children[3].children[0], equals(widgets[6])); + }); + + test('builds vertically', () { + final builder = new TabularLegendLayout.verticalFirst(); + final widgets = [new Text('1'), new Text('2'), new Text('3')]; + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 3); + expect(layout.children[0].children.length, 1); + expect(layout.children[1].children.length, 1); + expect(layout.children[2].children.length, 1); + }); + + test('does not build extra rows if max rows exceed widget count', () { + final builder = new TabularLegendLayout.verticalFirst(desiredMaxRows: 10); + final widgets = [new Text('1'), new Text('2'), new Text('3')]; + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 3); + expect(layout.children[0].children.length, 1); + expect(layout.children[1].children.length, 1); + expect(layout.children[2].children.length, 1); + }); + + test('builds vertically until max column exceeded', () { + final builder = new TabularLegendLayout.verticalFirst(desiredMaxRows: 2); + + final widgets = new List.generate( + 7, (int index) => new Text(index.toString())); + + final Table layout = builder.build(context, widgets); + expect(layout.children.length, 2); + + expect(layout.children[0].children[0], equals(widgets[0])); + expect(layout.children[1].children[0], equals(widgets[1])); + + expect(layout.children[0].children[1], equals(widgets[2])); + expect(layout.children[1].children[1], equals(widgets[3])); + + expect(layout.children[0].children[2], equals(widgets[4])); + expect(layout.children[1].children[2], equals(widgets[5])); + + expect(layout.children[0].children[3], equals(widgets[6])); + }); + }); +} diff --git a/charts_flutter/test/widget_layout_delegate_test.dart b/charts_flutter/test/widget_layout_delegate_test.dart new file mode 100644 index 000000000..9e3fb147f --- /dev/null +++ b/charts_flutter/test/widget_layout_delegate_test.dart @@ -0,0 +1,546 @@ +// Copyright 2018 the Charts project authors. Please see the AUTHORS file +// for details. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'dart:math' show Rectangle; +import 'package:flutter/material.dart'; +import 'package:mockito/mockito.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:charts_flutter/src/behaviors/chart_behavior.dart'; +import 'package:charts_flutter/src/widget_layout_delegate.dart'; + +const chartContainerLayoutID = 'chartContainer'; + +class MockBuildableBehavior extends Mock implements BuildableBehavior {} + +void main() { + group('widget layout test', () { + final chartKey = new UniqueKey(); + final behaviorKey = new UniqueKey(); + final behaviorID = 'behavior'; + final totalSize = const Size(200.0, 100.0); + final behaviorSize = const Size(50.0, 50.0); + + /// Creates widget for testing. + Widget createWidget( + Size chartSize, Size behaviorSize, BuildablePosition position, + {OutsideJustification outsideJustification, + InsideJustification insideJustification, + Rectangle drawAreaBounds, + bool isRTL: false}) { + // Create a mock buildable behavior that returns information about the + // position and justification desired. + final behavior = new MockBuildableBehavior(); + when(behavior.position).thenReturn(position); + when(behavior.outsideJustification).thenReturn(outsideJustification); + when(behavior.insideJustification).thenReturn(insideJustification); + when(behavior.drawAreaBounds).thenReturn(drawAreaBounds); + + // The 'chart' widget that expands to the full size allowed to test that + // the behavior widget's size affects the size given to the chart. + final chart = new LayoutId( + key: chartKey, id: chartContainerLayoutID, child: new Container()); + + // A behavior widget + final behaviorWidget = new LayoutId( + key: behaviorKey, + id: behaviorID, + child: new SizedBox.fromSize(size: behaviorSize)); + + // Create a the widget that uses the layout delegate that is being tested. + final layout = new CustomMultiChildLayout( + delegate: new WidgetLayoutDelegate( + chartContainerLayoutID, {behaviorID: behavior}, isRTL), + children: [chart, behaviorWidget]); + + final container = new Align( + alignment: Alignment.topLeft, + child: new Container( + width: chartSize.width, height: chartSize.height, child: layout)); + + return container; + } + + // Verifies the expected results. + void verifyResults(WidgetTester tester, Size expectedChartSize, + Offset expectedChartOffset, Offset expectedBehaviorOffset) { + final RenderBox chartBox = tester.firstRenderObject(find.byKey(chartKey)); + expect(chartBox.size, equals(expectedChartSize)); + + final chartOffset = chartBox.localToGlobal(Offset.zero); + expect(chartOffset, equals(expectedChartOffset)); + + final RenderBox behaviorBox = + tester.firstRenderObject(find.byKey(behaviorKey)); + final behaviorOffset = behaviorBox.localToGlobal(Offset.zero); + expect(behaviorOffset, equals(expectedBehaviorOffset)); + } + + testWidgets('Position top - start draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.startDrawArea; + final drawAreaBounds = const Rectangle(25, 50, 150, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to draw area + final expectedBehaviorOffset = const Offset(25.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position bottom - end draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.bottom; + final outsideJustification = OutsideJustification.endDrawArea; + final drawAreaBounds = const Rectangle(25, 0, 125, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the bottom, so the chart is offset by 0. + final expectedChartOffset = const Offset(0.0, 0.0); + // Behavior is aligned to draw area and offset to the bottom. + final expectedBehaviorOffset = const Offset(100.0, 50.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position start - start draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.startDrawArea; + final drawAreaBounds = const Rectangle(75, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the start (left) since this is NOT a RTL + // so the chart is offset to the right by the behavior width of 50. + final expectedChartOffset = const Offset(50.0, 0.0); + // Behavior is aligned to draw area. + final expectedBehaviorOffset = const Offset(0.0, 25.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position end - end draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.end; + final outsideJustification = OutsideJustification.endDrawArea; + final drawAreaBounds = const Rectangle(25, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the right (left) since this is NOT a RTL + // so no offset for the chart. + final expectedChartOffset = const Offset(0.0, 0.0); + // Behavior is aligned to draw area and offset to the right of the + // chart. + final expectedBehaviorOffset = const Offset(150.0, 25.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position top - start justified', (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.start; + final drawAreaBounds = const Rectangle(25, 50, 150, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to the start, so no offset + final expectedBehaviorOffset = const Offset(0.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position top - end justified', (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.end; + final drawAreaBounds = const Rectangle(25, 50, 150, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to the end, so it is offset by total size minus + // the behavior size. + final expectedBehaviorOffset = const Offset(150.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position start - start justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.start; + final drawAreaBounds = const Rectangle(75, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the start (left) since this is NOT a RTL + // so the chart is offset to the right by the behavior width of 50. + final expectedChartOffset = const Offset(50.0, 0.0); + // No offset because it is start justified. + final expectedBehaviorOffset = const Offset(0.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position start - end justified', (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.end; + final drawAreaBounds = const Rectangle(75, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the start (left) since this is NOT a RTL + // so the chart is offset to the right by the behavior width of 50. + final expectedChartOffset = const Offset(50.0, 0.0); + // End justified, total height minus behavior height + final expectedBehaviorOffset = const Offset(0.0, 50.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position inside - top start justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.inside; + final insideJustification = InsideJustification.topStart; + final drawAreaBounds = const Rectangle(25, 25, 175, 75); + + // Behavior is layered on top, chart uses the full size. + final expectedChartSize = const Size(200.0, 100.0); + // No offset since chart takes up full size. + final expectedChartOffset = const Offset(0.0, 0.0); + // Top start justified, no offset + final expectedBehaviorOffset = const Offset(0.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + insideJustification: insideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('Position inside - top end justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.inside; + final insideJustification = InsideJustification.topEnd; + final drawAreaBounds = const Rectangle(25, 25, 175, 75); + + // Behavior is layered on top, chart uses the full size. + final expectedChartSize = const Size(200.0, 100.0); + // No offset since chart takes up full size. + final expectedChartOffset = const Offset(0.0, 0.0); + // Offset to the top end + final expectedBehaviorOffset = const Offset(150.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + insideJustification: insideJustification, + drawAreaBounds: drawAreaBounds)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position top - start draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.startDrawArea; + final drawAreaBounds = const Rectangle(0, 50, 175, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to start draw area, which is to the left in RTL + final expectedBehaviorOffset = const Offset(125.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position bottom - end draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.bottom; + final outsideJustification = OutsideJustification.endDrawArea; + final drawAreaBounds = const Rectangle(0, 0, 175, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the bottom, so the chart is offset by 0. + final expectedChartOffset = const Offset(0.0, 0.0); + // Behavior is aligned to end draw area (left) and offset to the bottom. + final expectedBehaviorOffset = const Offset(0.0, 50.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position start - start draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.startDrawArea; + final drawAreaBounds = const Rectangle(0, 25, 125, 75); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Chart is on the left, so no offset. + final expectedChartOffset = const Offset(0.0, 0.0); + // Behavior is positioned at the start (right) and start draw area. + final expectedBehaviorOffset = const Offset(150.0, 25.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position end - end draw area justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.end; + final outsideJustification = OutsideJustification.endDrawArea; + final drawAreaBounds = const Rectangle(75, 25, 125, 75); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Chart is to the left of the behavior because of RTL. + final expectedChartOffset = const Offset(50.0, 0.0); + // Behavior is aligned to end draw area. + final expectedBehaviorOffset = const Offset(0.0, 50.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position top - start justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.start; + final drawAreaBounds = const Rectangle(25, 50, 150, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to the end, offset by behavior size. + final expectedBehaviorOffset = const Offset(150.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position top - end justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.top; + final outsideJustification = OutsideJustification.end; + final drawAreaBounds = const Rectangle(25, 50, 150, 50); + + // Behavior takes up 50 height, so 50 height remains for the chart. + final expectedChartSize = const Size(200.0, 50.0); + // Behavior is positioned on the top, so the chart is offset by 50. + final expectedChartOffset = const Offset(0.0, 50.0); + // Behavior is aligned to the end, no offset. + final expectedBehaviorOffset = const Offset(0.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position start - start justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.start; + final drawAreaBounds = const Rectangle(75, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the right since this is RTL so the chart is + // has no offset. + final expectedChartOffset = const Offset(0.0, 0.0); + // No offset because it is start justified. + final expectedBehaviorOffset = const Offset(150.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position start - end justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.start; + final outsideJustification = OutsideJustification.end; + final drawAreaBounds = const Rectangle(75, 25, 150, 50); + + // Behavior takes up 50 width, so 150 width remains for the chart. + final expectedChartSize = const Size(150.0, 100.0); + // Behavior is positioned at the right since this is RTL so the chart is + // has no offset. + final expectedChartOffset = const Offset(0.0, 0.0); + // End justified, total height minus behavior height + final expectedBehaviorOffset = const Offset(150.0, 50.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + outsideJustification: outsideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position inside - top start justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.inside; + final insideJustification = InsideJustification.topStart; + final drawAreaBounds = const Rectangle(25, 25, 175, 75); + + // Behavior is layered on top, chart uses the full size. + final expectedChartSize = const Size(200.0, 100.0); + // No offset since chart takes up full size. + final expectedChartOffset = const Offset(0.0, 0.0); + // Offset to the right + final expectedBehaviorOffset = const Offset(150.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + insideJustification: insideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + + testWidgets('RTL - Position inside - top end justified', + (WidgetTester tester) async { + final behaviorPosition = BuildablePosition.inside; + final insideJustification = InsideJustification.topEnd; + final drawAreaBounds = const Rectangle(25, 25, 175, 75); + + // Behavior is layered on top, chart uses the full size. + final expectedChartSize = const Size(200.0, 100.0); + // No offset since chart takes up full size. + final expectedChartOffset = const Offset(0.0, 0.0); + // No offset, since end is to the left. + final expectedBehaviorOffset = const Offset(0.0, 0.0); + + await tester.pumpWidget(createWidget( + totalSize, behaviorSize, behaviorPosition, + insideJustification: insideJustification, + drawAreaBounds: drawAreaBounds, + isRTL: true)); + + verifyResults(tester, expectedChartSize, expectedChartOffset, + expectedBehaviorOffset); + }); + }); +} diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index e5b39d04f..000000000 --- a/docs/index.md +++ /dev/null @@ -1,3 +0,0 @@ -# Coming soon - -Stay tuned.