From 38ab1dcac36801c7c68463ebc635bc581913d468 Mon Sep 17 00:00:00 2001 From: Douglas Robertson Date: Thu, 9 May 2024 11:19:02 -0600 Subject: [PATCH] add first working release, with line graph only --- .gitignore | 4 + CONTRIBUTING.md | 3 + LICENSE.txt | 21 +++ README.md | 81 ++++++++++ changelog.txt | 6 + manifest.xml | 36 +++++ monkey.jungle | 2 + resources/strings.xml | 3 + source/CIQToolsGraphing.mc | 306 +++++++++++++++++++++++++++++++++++++ 9 files changed, 462 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE.txt create mode 100644 changelog.txt create mode 100644 manifest.xml create mode 100644 monkey.jungle create mode 100644 resources/strings.xml create mode 100644 source/CIQToolsGraphing.mc diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ee7908 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +bin/* +builds/* +/.vscode/settings.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6b1775e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,3 @@ +# Contributing + +Fork the repo and make a pull request for any additions, fixes or updates. diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..9bfc3ef --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023-2024 Douglas Robertson (douglas@edgeoftheearth.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 09530e3..55334f3 100644 --- a/README.md +++ b/README.md @@ -1 +1,82 @@ # CIQ Tools Graphing +(c)2023-2024 Douglas Robertson + +Author: Douglas Robertson (GitHub: [douglasr](https://github.com/douglasr); Garmin Connect: dbrobert) + +## Overview +For the best user experience and minial friction, it is usually ideal to try and keep the interface and flow on apps similar to the native Garmin functionality. As such, the CIQ Tools Graphic barrel is a clone of the graphing functionality present within Garmin devices natively. + +## License +This Connect IQ barrel is licensed under the "MIT License", which essentially means that while the original author retains the copyright to the original code, you are free to do whatever you'd like with this code (or any derivative of it). See the LICENSE.txt file for complete details. + +## Graph Types +The following graph types are currently available: +- Line Graph (generic and 7-day weekly) + +More graphs will be made available as time permits. If you want/need a graph type not yet available, consider helping out. See the CONTRIBUTING.md file for details. + +## Using the Barrel +This project cannot be used on it's own; it is designed to be included in existing projects. + +### Include the Barrel +Download the barrel file (and associated debug.xml) and include it in your project. +See [Shareable Libraries](https://developer.garmin.com/connect-iq/core-topics/shareable-libraries/) on the Connect IQ Developer site for more details. + +### Displaying the Graph +The graphing module contains classes that extend the ```Toybox.WatchUi.Drawable``` object, which then render the actual graph. As such, you can display graphs within your app by either adding the drawable to a layout or by creating a graph drawable object and calling the ```draw()``` function on it. + +Regardless of the method used to render you graph, you will still need to pass the data points (and, optionally, labels for the X-axis); see below for details on that. + +#### Add Graph to a Layout +To add a graph to a layout simply include a `````` tag: +``` + + 40 + 100 + 180 + 100 + true + 0x000000 + 0xAAAAAA + 0x00AAFF + 0xFFFFFF + Graphics.FONT_SYSTEM_XTINY + @CIQToolsGraphing.LINE_STYLE_DOTTED + false + +``` + +#### Display the Graph Directly (via code) +``` +var lineGraph = new CIQToolsGraphing.Line.Generic({ + :identifier => "LineGraph" + :locX => 40, + :locY => 100, + :width => 180, + :height => 100, + :visible => true, + :bgColor => 0x000000, + :graphColor => 0xAAAAAA, + :pointColor => 0x00AAFF, + :textColor => 0xFFFFFF, + :textFont => Graphics.FONT_SYSTEM_XTINY, + :lineStyle => CIQToolsGraphing.LINE_STYLE_DOTTED, + :border => false +}); +lineGraph.draw(); +``` + +### Adding/Updating Data for the Graph +Data for the graph must be added dynamically, by calling the ```setDataPoints()``` function on the appropriate graphic object. + +``` +var dataPoints = [5,8,2,12,11,14,10]; +var lineGraph = View.findDrawableById("LineGraph") as CIQToolsGraphing.Line.Weekly; +lineGraph.setDataPoints(dataPoints); +``` + +## Contributing +Please see the CONTRIBUTING.md file for details on how contribute. + +### Contributors +* [Douglas Robertson](https://github.com/douglasr) diff --git a/changelog.txt b/changelog.txt new file mode 100644 index 0000000..a57e23a --- /dev/null +++ b/changelog.txt @@ -0,0 +1,6 @@ +CIQ Tools - Graphing +-------------------- +(c)2023-2024 Douglas Robertson (douglas@edgeoftheearth.com) + +0.1.0 -- 09-May-2024 + - first release (partial) diff --git a/manifest.xml b/manifest.xml new file mode 100644 index 0000000..e8c829e --- /dev/null +++ b/manifest.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + LineGraph + + + \ No newline at end of file diff --git a/monkey.jungle b/monkey.jungle new file mode 100644 index 0000000..f6d147d --- /dev/null +++ b/monkey.jungle @@ -0,0 +1,2 @@ +project.manifest = manifest.xml +project.typecheck = 2 diff --git a/resources/strings.xml b/resources/strings.xml new file mode 100644 index 0000000..d1d62f2 --- /dev/null +++ b/resources/strings.xml @@ -0,0 +1,3 @@ + + M,T,W,T,F,S,S + diff --git a/source/CIQToolsGraphing.mc b/source/CIQToolsGraphing.mc new file mode 100644 index 0000000..aca6a2d --- /dev/null +++ b/source/CIQToolsGraphing.mc @@ -0,0 +1,306 @@ +/* +MIT License + +Copyright (c) 2023-2024 Douglas Robertson (douglas@edgeoftheearth.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Author: Douglas Robertson (GitHub: douglasr; Garmin Connect: dbrobert) +*/ + +import Toybox.Graphics; +import Toybox.Lang; +import Toybox.Time; +import Toybox.WatchUi; + +//! TODO - describe module here +module CIQToolsGraphing { + + //! TODO - describe enumeration uses here + enum { + LINE_STYLE_DOTTED, + LINE_STYLE_SOLID + } + + //! TODO - describe module here + (:LineGraph) + module Line { + + //! TODO - describe class here + class Generic extends WatchUi.Drawable { + private var _columnWidth as Number; + private var _rowHeight as Number; + private var _graphLineWidth as Number; + private var _dataPoints as [Numeric]; + private var _textColor as ColorType; + private var _textFont as FontType; + private var _bgColor as ColorType; + private var _graphColor as ColorType; + private var _pointColor as ColorType; + private var _useBorder as Boolean; + private var _graphMinValue as Numeric; + private var _graphMaxValue as Numeric; + private var _lineStyle as Number = CIQToolsGraphing.LINE_STYLE_DOTTED; + + protected var _useXAxisLabels as Boolean; + protected var _useYAxisLabels as Boolean; + protected var _yAxisLabels as Array or Null; + protected var _xAxisLabels as Array or Null; + + //! Constructor + function initialize(params as { :identifier as Lang.Object, :locX as Lang.Numeric, :locY as Lang.Numeric, :width as Lang.Numeric, :height as Lang.Numeric, :visible as Lang.Boolean }) { + Drawable.initialize(params); + setLocation(params[:x] as Number, params[:y] as Number); + setSize(params[:width] as Number, params[:height] as Number); + setVisible(params[:visible] == null || params[:visible] != false); + _textColor = params[:textColor] as ColorType; + _textFont = params[:textFont] as FontType; + _bgColor = params[:bgColor] as ColorType; + _graphColor = params[:graphColor] as ColorType; + _pointColor = params[:pointColor] as ColorType; + _useXAxisLabels = ((params[:xAxisLabels] as Boolean) != false); + _useYAxisLabels = ((params[:yAxisLabels] as Boolean) != false); + _useBorder = ((params[:useBorder] as Boolean) == true); + + _columnWidth = width; // default to one column, full width + _rowHeight = Math.round(height/5.5).toNumber(); + _graphLineWidth = Math.round(System.getDeviceSettings().screenWidth * 0.005).toNumber(); + + _dataPoints = [] as [Numeric]; + _graphMinValue = 0; + _graphMaxValue = 0; + } + + //! TODO - describe function here + function setDataPoints(points as Array) as Void { + // slice the data points if there are too many and assign to instance variable + if (points.size() > 0) { + var tmpSlice = points.slice(0,7) as [Numeric]; + if (tmpSlice != null) { + _dataPoints = tmpSlice; + } + } + + // go through the data points and determine min/max + if (_dataPoints.size() > 0) { + var minValue = 0; + var maxValue = 0; + // calculate the y-axis values, converting to printable strings (ie. 15,000 => "15k"), as required + for (var i=0; i < _dataPoints.size(); i++) { + if (_dataPoints[i] == null) { + continue; + } + if (_dataPoints[i] < minValue) { + minValue = _dataPoints[i]; + } else if (_dataPoints[i] > maxValue) { + maxValue = _dataPoints[i]; + } + } + _graphMinValue = minValue; + _graphMaxValue = maxValue; + + // determine how many columns to show (will be the greater of data points size or x-axis label size) + var numCols = _dataPoints.size(); + if (_xAxisLabels != null && _xAxisLabels.size() > numCols) { + numCols = _xAxisLabels.size(); + } + _columnWidth = Math.round(width/numCols).toNumber(); + + } else { + _graphMinValue = 0; + _graphMaxValue = 0; + } + + // calculate the axis values to display (based on min/max values of data points) + if (_graphMinValue == 0 && _graphMaxValue == 0) { + _graphMaxValue = 1; + _yAxisLabels = [_graphMinValue.toString(), null, null, null, _graphMaxValue.toString()]; + } else { + var midValue = _graphMaxValue - ((_graphMaxValue-_graphMinValue)/2.0); + var fracVal = midValue - (Math.floor(midValue).toNumber()); + var midValueStr = ""; + if (fracVal == 0.0) { + midValueStr = midValue.format("%0d"); + } else { + midValueStr = midValue.format("%0.1f"); + } + _yAxisLabels = [null, null, midValueStr, null, _graphMaxValue.toString()]; + } + } + + //! TODO - describe function here + function setXAxisLabels(labels as Array) as Void { + _xAxisLabels = labels; + } + + //! TODO - describe function here + //! @param dc Device context + function draw(dc as Graphics.Dc) as Void { + if (!isVisible) { + return; + } + + // draw the 5 horizontal lines of the graph (and, Y-axis values) + dc.setColor(_graphColor, Graphics.COLOR_TRANSPARENT); + dc.setPenWidth(_graphLineWidth); + for (var i=0; i < 5; i++) { + var rowY = locY+(_rowHeight*i)+(_rowHeight/2); + if (_lineStyle == CIQToolsGraphing.LINE_STYLE_SOLID) { + dc.drawLine(locX, rowY, locX+width, rowY); + } else { + // default for line style, if param is unrecognized, is dotted + for (var j=0; j < width; j=j+(_graphLineWidth*2)) { + dc.drawPoint(locX+j, rowY); + } + } + } + + // draw the Y-axis labels, if configured + if (_useYAxisLabels && _yAxisLabels != null) { + var axisValuesXOffset = dc.getTextWidthInPixels(" ", _textFont); + dc.setColor(_textColor, Graphics.COLOR_TRANSPARENT); + for (var i=0; i < 5; i++) { + if (_yAxisLabels[i] != null) { + var rowY = locY+height-(_rowHeight*(i+1)); + dc.drawText(locX+width+axisValuesXOffset, rowY, _textFont, _yAxisLabels[i], Graphics.TEXT_JUSTIFY_LEFT|Graphics.TEXT_JUSTIFY_VCENTER); + } + } + } + + // draw the X-axis labels + if (_useXAxisLabels && _xAxisLabels != null) { + dc.setColor(_textColor, Graphics.COLOR_TRANSPARENT); + var textY = locY + height - (_rowHeight/2); + for (var i=0; i < _xAxisLabels.size(); i++) { + dc.drawText(locX+(_columnWidth*i)+(_columnWidth/2), textY, Graphics.FONT_SYSTEM_XTINY, _xAxisLabels[i], Graphics.TEXT_JUSTIFY_CENTER|Graphics.TEXT_JUSTIFY_VCENTER); + } + } + + // draw the data points + if (_dataPoints != null) { + var valuePerPixel = (_graphMaxValue-_graphMinValue).toFloat()/(_rowHeight*4).toFloat(); + var previousPoint = null as [Numeric, Numeric]?; + var pointRadius = 3; // FIXME: this should be dynamicly calculated based on device width/height + dc.setColor(_pointColor, _bgColor); + for (var i=0; i < _dataPoints.size(); i++) { + if (_dataPoints[i] == null) { + continue; + } + var dataPointX = locX+(_columnWidth*i)+(_columnWidth/2); + var dataPointY = locY + (_rowHeight*4.5) - Math.round(_dataPoints[i].toFloat()/valuePerPixel).toNumber(); + dc.setColor(_pointColor, _bgColor); + dc.fillCircle(dataPointX, dataPointY, pointRadius); + // if + if (previousPoint != null) { + dc.setPenWidth(2); + dc.drawLine(previousPoint[0], previousPoint[1], dataPointX, dataPointY); + dc.setColor(_bgColor, _bgColor); + dc.setPenWidth(1); + dc.drawCircle(previousPoint[0], previousPoint[1], pointRadius+1); + dc.drawCircle(dataPointX, dataPointY, pointRadius+1); + } + previousPoint = [dataPointX, dataPointY]; + } + } + + // draw the border + if (_useBorder) { + // carve out a 1 pixel blank area around the border + dc.setPenWidth(1); + dc.setColor(_bgColor, _bgColor); + dc.drawRectangle(locX-1, locY-1, width+2, height+2); + // draw border + dc.setColor(_graphColor, Graphics.COLOR_TRANSPARENT); + dc.drawRectangle(locX, locY, width, height); + } + } + } + + //! TODO - describe class here + class Weekly extends Line.Generic { + + function initialize(params as { :identifier as Lang.Object, :locX as Lang.Numeric, :locY as Lang.Numeric, :width as Lang.Numeric, :height as Lang.Numeric, :visible as Lang.Boolean }) { + Line.Generic.initialize(params); + + if (_useXAxisLabels) { + // the "daysOfWeekAbbr" param can be one of the following: + // - null (will default to English/default string resource named "DaysOfWeekAbbr") + // - an array of strings (eg. ["M","T","W","T","F","S","S"]) + // - a single string of comma separated values (eg. "M,T,W,T,F,S,S") + var xLabels = null; + var tmpDays = params[:daysOfWeekAbbr] as String or [String]; + if (tmpDays != null && tmpDays instanceof Lang.Array) { + if (tmpDays.size() >= 7) { + xLabels = tmpDays.slice(0,7) as [String]; + } + } + if (xLabels == null) { + // we will treat the value param as a single string of comma-separated values + if (tmpDays == null) { + // if not yet assigned (due to missing param or invalid data), + // then default to abbreviations for English days of the week + tmpDays = WatchUi.loadResource(Rez.Strings.DaysOfWeekAbbr) as String; + } + // parse out the values within the string and assign to an array + xLabels = parseDaysOfWeekAbbrs(tmpDays as String); + } + + // now set the x-axis labels shifted based on today's day of week + if (xLabels.size() == 7) { + var currentDOW = Gregorian.info(Time.now(), Time.FORMAT_SHORT).day_of_week + 5; // add 5 to start week at Monday (dow % 7 == 0); + var startDOW = currentDOW + 1; // start on the next day of the week (as the oldest, most left value) + var tmpDOW = new [7]; + for (var i=0; i < 7; i++) { + tmpDOW[i] = xLabels[(startDOW+i)%7]; + } + xLabels = tmpDOW; + + } else { + // if the parsing didn't work out, then don't display anything + xLabels = []; + } + + _xAxisLabels = xLabels; + } + } + + private + + //! TODO - describe function here + function parseDaysOfWeekAbbrs(abbrStr as String) as [String] { + if (abbrStr == null || abbrStr.equals("")) { + return ([] as [String]); + } + var daysOfWeekAbbrs = [] as [String]; + var tmpStr = abbrStr as String; + var commaIdx = tmpStr.find(","); + while (commaIdx != null) { + daysOfWeekAbbrs.add(tmpStr.substring(0,commaIdx) as String); + tmpStr = tmpStr.substring(commaIdx+1,99) as String; + commaIdx = tmpStr.find(","); + } + daysOfWeekAbbrs.add(tmpStr as String); + + return (daysOfWeekAbbrs as [String]); + } + + } + + } +} \ No newline at end of file