User Rating Scorecard, Part II

“Critics are our friends, they show us our faults.”
Benjamin Franklin

Now that I had a concept for displaying the results of collecting feedback, I just needed to build the Angular Provider to produce the desired output. I had already built a couple of other Angular Providers for my Form Field and User Help efforts, so I was a little familiar with the concept. Still, I learned quite a lot during this particular adventure.

To start with, I had never used variables in CSS before. In fact, I never really knew that you could even do something like that. I stumbled across the concept looking for a way to display partial stars in the rating graphic, and ended up using it in displaying the colored bars in the rating breakdown chart as well. For the rating graphic, here is the final version of the CSS that I ended up with:

:root {
	--star-size: x-large;
	--star-color: #ccc;
	--star-background: #fc0;
}

.snh-rating {
	--percent: calc(var(--rating) / 5 * 100%);
	display: inline-block;
	font-size: var(--star-size);
	font-family: Times;
	line-height: 1;
}
  
.snh-rating::before {
	content: '★★★★★';
	background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
	-webkit-background-clip: text;
	-webkit-text-fill-color: transparent;
}

The portion of the HTML that produces the star rating came out to be this:

<div class="snh-rating" style="--rating: {{average}};"></div>

… and the average value was calculated by adding up all of the values and dividing by the number of votes:

$scope.valueString = $scope.$eval($attributes.snhValues);
$scope.values = $scope.valueString.split(',');
$scope.votes = 0;
$scope.total = 0;
for (var i=0; i<$scope.values.length; i++) {
	var votes = parseInt($scope.values[i]);
	$scope.votes += votes;
	$scope.total += votes * (i + 1);
}
$scope.average = ($scope.total/$scope.votes).toFixed(2);

The content is simply 5 star characters and then the linear-gradient background controls how much of the five stars are highlighted. The computed average score passed as a variable allows the script to dictate to the stylesheet the desired position of the end of the highlighted area. Pretty slick stuff, and this part I actually understand!

Once I figured all of that out, I was able to adapt the same concept to the graph that illustrated the breakdown of votes cast. In the case of the graph, I needed to find the largest vote count to set the scale of the graph, to which I added 10% padding so that even the largest bar wouldn’t go all the way across. To figure all of that out, I just needed to expand a little bit on the code above:

link: function ($scope, $element, $attributes) {
	$scope.valueString = $scope.$eval($attributes.snhValues);
	$scope.values = $scope.valueString.split(',');
	$scope.votes = 0;
	$scope.total = 0;
	var max = 0;
	for (var i=0; i<$scope.values.length; i++) {
		var votes = parseInt($scope.values[i]);
		$scope.votes += votes;
		$scope.total += votes * (i + 1);
		if (votes > max) {
			max = votes;
		}
	}
	$scope.bar = [];
	for (var i=0; i<$scope.values.length; i++) {
		$scope.bar[i] = (($scope.values[i] * 100) / (max * 1.1)) + '%';
	}
	$scope.average = ($scope.total/$scope.votes).toFixed(2);
},

The CSS to set the bar length then just needed to reference a variable:

.snh-rating-bar {
	width: var(--bar-length);
	height: 18px;
}

… and then the HTML for the bar just needed to pass in relevant value:

<div style="--bar-length: {{bar[0]}};" class="snh-rating-bar snh-rating-bar-1"></div>

All together, the entire Angular Provider came out like this:

function() {
	return {
		restrict: 'E',
		replace: true,
		link: function ($scope, $element, $attributes) {
			$scope.valueString = $scope.$eval($attributes.snhValues);
			$scope.values = $scope.valueString.split(',');
			$scope.votes = 0;
			$scope.total = 0;
			var max = 0;
			for (var i=0; i<$scope.values.length; i++) {
				var votes = parseInt($scope.values[i]);
				$scope.votes += votes;
				$scope.total += votes * (i + 1);
				if (votes > max) {
					max = votes;
				}
			}
			$scope.bar = [];
			for (var i=0; i<$scope.values.length; i++) {
				$scope.bar[i] = (($scope.values[i] * 100) / (max * 1.1)) + '%';
			}
			$scope.average = ($scope.total/$scope.votes).toFixed(2);
		},
		template: '<div>\n' +
			'  <div ng-hide="votes > 0">\n' +
			'    This item has not yet been rated.\n' +
			'  </div>\n' +
			'  <div ng-show="votes > 0">\n' +
			'    <div class="snh-rating" style="--rating: {{average}};"></div>\n' +
			'    <div style="clear: both;"></div>\n' +
			'    {{average}} average based on {{votes}} reviews.\n' +
			'    <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 1;" ng-hide="c.data.show_breakdown == 1">Show breakdown</a>\n' +
			'    <div ng-show="c.data.show_breakdown == 1" style="background-color: #ffffff; max-width: 500px; padding: 15px;">\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>5 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[4]}};" class="snh-rating-bar snh-rating-bar-5"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[4]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>4 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[3]}};" class="snh-rating-bar snh-rating-bar-4"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[3]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>3 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[2]}};" class="snh-rating-bar snh-rating-bar-3"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[2]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>2 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[1]}};" class="snh-rating-bar snh-rating-bar-2"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[1]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div class="snh-rating-row">\n' +
			'        <div class="snh-rating-side">\n' +
			'          <div>1 star</div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-middle">\n' +
			'          <div class="snh-rating-bar-container">\n' +
			'            <div style="--bar-length: {{bar[0]}};" class="snh-rating-bar snh-rating-bar-1"></div>\n' +
			'          </div>\n' +
			'        </div>\n' +
			'        <div class="snh-rating-side snh-rating-right">\n' +
			'          <div>{{values[0]}}</div>\n' +
			'        </div>\n' +
			'      </div>\n' +
			'      <div style="text-align: center;">\n' +
			'        <a href="javascript:void(0);" ng-click="c.data.show_breakdown = 0;">Hide breakdown</a>\n' +
			'      </div>\n' +
			'    </div>\n' + 
			'  </div>\n' + 
			'</div>\n'
	};
}

… and here is the accompanying CSS style sheet:

:root {
	--star-size: x-large;
	--star-color: #ccc;
	--star-background: #fc0;
}

.snh-rating {
	--percent: calc(var(--rating) / 5 * 100%);
	display: inline-block;
	font-size: var(--star-size);
	font-family: Times;
	line-height: 1;
}
  
.snh-rating::before {
	content: '★★★★★';
	background: linear-gradient(90deg, var(--star-background) var(--percent), var(--star-color) var(--percent));
	-webkit-background-clip: text;
	-webkit-text-fill-color: transparent;
}

* {
	box-sizing: border-box;
}

.snh-rating-side {
	float: left;
	width: 15%;
	margin-top: 10px;
}

.snh-rating-middle {
	margin-top: 10px;
	float: left;
	width: 70%;
}

.snh-rating-right {
	text-align: right;
}


.snh-rating-row:after {
	content: "";
	display: table;
	clear: both;
}

.snh-rating-bar-container {
	width: 100%;
	background-color: #f1f1f1;
	text-align: center;
	color: white;
}

.snh-rating-bar {
	width: var(--bar-length);
	height: 18px;
}

.snh-rating-bar-5 {
	background-color: #4CAF50;
}

.snh-rating-bar-4 {
	background-color: #2196F3;
}

.snh-rating-bar-3 {
	background-color: #00bcd4;
}

.snh-rating-bar-2 {
	background-color: #ff9800;
}

.snh-rating-bar-1 {
	background-color: #f44336;
}

@media (max-width: 400px) {
	.snh-rating-side, .snh-rating-middle {
		width: 100%;
	}
	.snh-rating-right {
		display: none;
	}
}

Now, I ended up hard-coding the number of stars, or possible rating points, throughout this entire exercise, which I am not necessarily all that proud of, but I did get it all to work. In my defense, the “5 Star” rating system seems to be almost universal, even if you aren’t dealing with “Stars” and are counting pizzas or happy faces. Hardly anyone uses 4 or 6 or 10 Whatevers to rate anything these days. Still, I would much prefer to be able to set both the number of items and the image for the item, just have a more flexible component. But then, this is just Version 1.0 … maybe one day some future version will actually have that capability. In the meantime, here is an Update Set for those of you who would like to tinker on your own.