3

I'm working on a directive that will allow a numeric field to be displayed in a friendly way based on the amount of space available for that number so if, for instance, you have a field with a value of 10,000,000,000 this would then show as 10B (B for billion) if there is not enough space for the full value of 10,000,000,000.

I wanted this to work with bootstrap. The best approach I could think of involved wrapping the element in a parent element which would be sized by bootstrap (and then the target element would react accordingly). I was able to get this working, but I imagine there's a better approach.

Here is an HTML example (the directive is the "responsive-number" attribute):

<div class="container-fluid">
    <div class="row">
        <div class="col-md-4">Some Label: </div>
        <div class="col-md-8">
            <div responsive-number ng-model="someValue"></div>
        </div>
    </div>
</div>

The directive, upon initial rendering, sets the element's max-width to the parent node's innerWidth(). A limitation here is that you can't have any other element contained within the parent node so I figure there must be a better, perhaps more passive, way to approach this by responding to the responsive-number element being resized (I'm just not sure how to do this while forcing the responsive-number element to remain within the bounds of the parent container).

Below is the javascript that is working for me based on the above HTML. I detect that the value is too large for the responsive-number container by setting the a text-overflow style such that the overflow is hidden via clipping - this is where the parent container comes in as I need to set the max-size of the element to the size of the parent container:

A jsfiddle example can be found here, but is slightly modified to use a jQuery event for the sake of convenience (but note I am aware this is a bad practice): http://jsfiddle.net/F52y5/63/

'use strict';

function MyCtrl($scope) {
    $scope.someValue = 100000000000000000;
}

app.directive('responsiveNumber', ['$filter', function ($filter) {
    return {
        require: 'ngModel',
        restrict: 'A',
        scope: true,
        link: function (scope, element, attrs, ctrl) {
            var scrollWidth = element[0].scrollWidth;
            var parentWidth = getParentWidth();
            var displayFriendlyValue = false;

            initializeResponsiveNumber();
            ctrl.$render();

            ctrl.$render = function (isResizeEvent) {
              var temp = shouldRenderFriendlyValue();

              if(isResizeEvent){
                // then the window was resized and the element is already formatted
                // so if it fits, then that's all she wrote..
                if (displayFriendlyValue == temp) {
                    return;
                }
              }

              displayFriendlyValue = temp;
              var viewValue = getTransformedViewValue(ctrl.$modelValue, displayFriendlyValue);
              ctrl.$viewValue = viewValue;
              element.html(ctrl.$viewValue);

              // after we've formatted the number it may not fit anymore
              // so if shouldRenderFriendlyValue() previously returned false
              // that means the unformatted number was not overflowing, but
              // the formatted number may overflow
              if (!displayFriendlyValue) {
                  // we check shouldRenderFriendlyValue() again
                  // because sizing can change after we set 
                  // element.html(ctrl.$viewValue);
                  displayFriendlyValue = shouldRenderFriendlyValue();
                  if (displayFriendlyValue) {
                    viewValue = getTransformedViewValue(ctrl.$modelValue, displayFriendlyValue);
                    ctrl.$viewValue = viewValue;
                    element.html(ctrl.$viewValue);
                    return;
                  }
              }
            };

            function getTransformedViewValue(modelValue, displayFriendlyValue){
              var result;
              // could add support for specifying native filter types 
              // (currency, for instance), but not necessary for this example
              if(displayFriendlyValue){
                result = makeFriendlyNumber(modelValue, 'number');
              } else {
                result = $filter('number')(modelValue);
              }

              return result;
            }

            function cleanNumber(num) {
                return (Math.round(num * 10) / 10);
            }

            function makeFriendlyNumber(num, filter) {
                var result;

                if (num >= 1000000000) {
                    result = $filter(filter)(cleanNumber(num / 1000000000)) + 'B';
                } else if (num >= 1000000) {
                    result = $filter(filter)(cleanNumber(num / 1000000)) + 'M';
                } else if (num >= 1000) {
                    result = $filter(filter)(cleanNumber(num / 1000)) + 'K';
                } else {
                    result = $filter(filter)(num);
                }

                return result.toString().replace(/(\.[\d]*)/, '');
            }

            function initializeResponsiveNumber() {
                element[0].style['overflow'] = 'hidden';
                element[0].style['-moz-text-overflow'] = 'clip';
                element[0].style['text-overflow'] = 'clip';

                updateElementSize(parentWidth);

                var debouncedResizeEvent = $scope.$on('debouncedresize', function (event) {
                    scope.$apply(function () {

                        var newParentWidth = getParentWidth();
                        if (newParentWidth == parentWidth) { return; }

                        parentWidth = newParentWidth;
                        updateElementSize(parentWidth);
                        ctrl.$render(true);
                    });
                });

                $scope.$on('$destroy', debouncedResizeEvent()};
            }

            function getParentWidth() {
                var innerWidth = angular.element(element[0].parentNode).innerWidth();
                return innerWidth;
            }

            function shouldRenderFriendlyValue() {
                scrollWidth = element[0].scrollWidth;
                if (element.innerWidth() < scrollWidth) {
                    return true;
                }

                return false;
            }

            function updateElementSize(width) {
                element[0].style.width = width + 'px';
            }
        }
    }
}]);

Any suggestions would be much appreciated!

Also worth noting I'm aware this function renders more times than necessary in some cases - but there's no sense in optimizing it if there's a better approach.

---------------update-------------

There has been some confusion here so I wanted to try to simplify the problem statement:

how do I know if there is enough space to show the full value or if I have to abbreviate it?

I've already demonstrated one way to approach this, but I'm not convinced it's the best way.

Jordan
  • 5,085
  • 7
  • 34
  • 50
  • 1
    Seems like a whole lot of effort. I'd be inclined to simply filter the numbers in all cases, or do so at mobile screen widths. http://stackoverflow.com/a/10601315/1264804 – isherwood Dec 05 '14 at 20:30
  • Upvote for the nice implementation.. but what is your question? – Aidin Dec 05 '14 at 22:17
  • thanks! seems like there should be a better way to approach this rather than having to use the parent element? – Jordan Dec 05 '14 at 22:37
  • @isherwood: Unfortunately, in this scenario, resizing at standard widths (e.g. 762px) does not add much value - also, the values may change based on user input so it doesn't just have to do with resizing of the window - after the number has changed it may be possible to display the non-friendly value or it may be necessary to display the friendly value where it previously was a smaller value. I need to show numbers as specifically as possible, where possible, but there's a lot of info so sometimes it's preferred that a friendly value is displayed to preserve aesthetics. – Jordan Dec 06 '14 at 01:24

2 Answers2

1

My recommendation would be: Don't do this. Here are some of the reasons:

  • Responsive design should be controlled and consistent, not chaotic

    If you approach responsive design the way you're suggesting here (adjust elements individually based on their run-time allocated space), you'll end up with little to no idea about the various results (because there will be too many possibilities). Additionally, the results may be pretty inconsistent (e.g. different digits have different widths - numbers of same number of digits with the same ammount of space may or may not be shortened).

    It's better to just give your content enough space. If you know you're running out of space, you might provide multiple alternatives of content and switch to an alternative based on display size (consistently). E.g. on screens under 480px wide, you might hide the "long number" alternative and show the "short number" instead.

  • Content formatting should aim for optimal readability

    Large numbers are generally not very readable. You might want to shorten them (and provide units) for better readability in general, not only in places where they can't fit in their expanded format.

    Keep in mind that users don't just read a single number. Comparing multiple numbers is, for example, is a common use-case, and it can be hurt a lot by inconsistent formatting.

  • Watching dimensions on per-element basis can hurt performance

    AFAIK, there's no event notifying you of element dimension changes and a lot of things can trigger such changes (for example adding a non-related element to DOM might trigger different styling). Watching changes therefore requires some sort of periodic checking, which can have performance impact when used on a large number of elements.

hon2a
  • 7,006
  • 5
  • 41
  • 55
  • Good points here.. I'll have to think about this a bit more in the context of the use case (which unfortunately I can not discuss). It was requested that I do this but I reserve the right to advise them to go with a different option. – Jordan Dec 06 '14 at 13:28
  • Also to your last point, I know ng-model doesn't make sense here, but responsive-number is actually an attribute on a larger directive which makes the content of the element editable. I've isolated the necessary logic into a separate directive for the purpose of this example. – Jordan Dec 06 '14 at 13:32
  • Ah, ok. I've removed the suggestion regarding `ng-model` from the answer. – hon2a Dec 06 '14 at 13:49
  • Appreciate it - already up-voted since your answer is certainly helpful and you make very good points, but it still may make sense to use "responsively" abbreviated values in this particular scenario – Jordan Dec 06 '14 at 14:38
0

Seems to me that you could just do this with a filter, This is just a rough idea of how that might work and I've not dealt with the 1000-9999 range for brevity of code, but that shouldn't be much of an issue to work around.

/**
 * var value = 12345678;
 *
 * value|niceNumber     => 11M
 * value|niceNumber:2   => 17.73M
 */
 app.filter('niceNumber', function () {
    return function (num, precision) {
        if (isNaN(parseFloat(num)) || !isFinite(num)) { return '-'; }
        if (typeof precision === 'undefined') { precision = 0; }
        var units = ['bytes', '', 'M', 'B', 'T', 'P'],
            number = Math.floor(Math.log(num) / Math.log(1000));

        return (num / Math.pow(1000, Math.floor(number))).toFixed(precision) + units[number];
    };
});
Code Uniquely
  • 6,356
  • 4
  • 30
  • 40
  • While I appreciate your answer, the meat of the question is - how do I know if there is enough space to show the full value or if I have to abbreviate it (I've already demonstrated one way to approach this, but I'm not convinced it's the best way) – Jordan Dec 06 '14 at 11:09
  • 1
    OK, but ideally you want to represent you data in a consistent way as it helps the user keep a solid mental model of how things work. Displaying the number as 1,110,000 when they come in using one device or orientation and then switching to 1.11M depending on what they do with the device or screen messes with that mental model and adds to their cognitive load. This makes it harder for them. This may not be a major thing or blocking facto but its still makes it harder. – Code Uniquely Dec 07 '14 at 09:00
  • Yes that is a valid point and admittedly I got so caught up in the challenge of coming up with a solution that I lost sight of this. That said - I would still be interested in how to best solve this problem without relying on standard screen sizes. It would be easy enough to have the directive tell the root scope if it is showing a friendly value or the full value - if there is at least one "responsive number" that doesn't have enough space to show the full value, the root scope could publish an event telling all other numbers, for the given view/context, to show the friendly/abbreviated value – Jordan Dec 07 '14 at 19:14