3
\$\begingroup\$

I've updated code from a couple weeks back so I've come back to get more feedback. The original post can be found over here:

Approximating/Sorting groups of dates into buckets

/* 
* @Author: Gowiem
* @Date: 2013年12月17日 14:21:17
*/
var Hist = Hist || {};
// Timeline Utils
//////////////////
Hist.TLUtils = (function() {
 var timeConversions = { "year": 31557600000,
 "month": 2628000000,
 "day": 86400000 };
 var pubConvertTime = function(howMany, type) {
 if (timeConversions.hasOwnProperty(type)) {
 return howMany * timeConversions[type];
 } else {
 console.assert(false, "Hist.TLUtils.convertTime was given unknown type: ", type);
 }
 };
 var pubRoundToDecade = function(date, shouldFloor) {
 var year = date.getFullYear(),
 remainder = year % 10,
 roundedYear = shouldFloor ? (year - remainder) - 10 : (year - remainder) + 10,
 roundedDate = new Date(date.getTime()).setFullYear(roundedYear);
 return roundedDate;
 };
 var pubGenerateRandomId = function() {
 return Math.floor((1 + Math.random()) * 0x10000)
 .toString(16)
 .substring(1);
 };
 return {
 roundToDecade: pubRoundToDecade,
 convertTime: pubConvertTime,
 generateRandomId: pubGenerateRandomId
 };
})();
// Timeline Objects
////////////////////
Hist.TLO = Hist.TLO || {};
Hist.TLO.range = function(beginEpoch, endEpoch) {
 return {
 begin: new Date(beginEpoch),
 end: new Date(endEpoch),
 differenceInYears: new Date(endEpoch).getYear() - new Date(beginEpoch).getYear(),
 halfwayDate: new Date(beginEpoch + ((endEpoch - beginEpoch)/2)),
 toString: function() {
 return "Range - begin: " + this.begin.toString() + " end: " + this.end.toString() + " halfwayDate: " + this.halfwayDate.toString();
 }
 };
};
// Our Collection of Point Objects
Hist.TLO.pointCollection = function(pages) {
 var collection = {},
 allPoints = [],
 current = [],
 pointPositions = {},
 point,
 // Util Aliases
 roundToDecade = Hist.TLUtils.roundToDecade,
 // TLO Aliases
 timelinePoint = Hist.TLO.timelinePoint,
 multiPoint = Hist.TLO.multiPoint;
 // Loop through the given pages and construct our timeline points
 pages.forEach(function(page, idx) {
 point = timelinePoint(page);
 if (point.isValid()) {
 allPoints.push(point);
 current.push(point);
 }
 });
 // Iterates through the timeline points to find their x and y positions
 // and stores them in pointPositions for later use. 
 // Returns { point.id => { x: xPos, y: yPos }, ... }
 var buildPointPositions = function(timelineRange) {
 var self = this,
 rangeDifference = timelineRange.differenceInYears,
 yPositions = {},
 xPos, yPos, approximaterMod;
 // Ranges:
 // 80+ years: buckets of 5 years
 // 60+ years: buckets of 4 years
 // 45+ years: buckets of 3 years
 // 30+ years: buckets of 2 years
 // 20+ years: Buckets of 1 years
 // TODO: Below are messed up. Need to be fixed
 // 10+ years: Buckets of 
 // 4+ years: Buckets of 
 // 4- years: No Range, Only stack if in same month
 if (rangeDifference >= 80) {
 approximaterMod = 10;
 console.log("=========== range is 80+");
 } else if (rangeDifference >= 60) {
 approximaterMod = 8;
 console.log("=========== range is 60+");
 } else if (rangeDifference >= 45) {
 approximaterMod = 6;
 console.log("=========== range is 45+");
 } else if (rangeDifference >= 30) {
 approximaterMod = 4;
 console.log("=========== range is 30+");
 } else if (rangeDifference >= 20) {
 approximaterMod = 2;
 console.log("=========== range is 20+");
 } else if (rangeDifference >= 10) {
 approximaterMod = 6; 
 console.log("=========== range is 10+");
 } else if (rangeDifference >= 4) {
 approximaterMod = 2;
 console.log("=========== range is 4+");
 } else {
 approximaterMod = null
 console.log("=========== range is 4-");
 }
 this.current.forEach(function(point, outerIndex) {
 xPos = null;
 if (rangeDifference > 20) {
 xPos = point.approxDateYear(approximaterMod);
 } else if (rangeDifference >= 4) {
 xPos = point.approxDateMonth(approximaterMod);
 } else {
 xPos = point.date;
 }
 if (xPos) {
 if (yPositions[xPos.toString()]) {
 yPos = yPositions[xPos.toString()];
 yPositions[xPos.toString()]++;
 } else {
 yPos = 0;
 yPositions[xPos.toString()] = 1;
 }
 // Set the x and y position of the current point
 self.pointPositions[point.id] = { 'x': xPos, 'y': yPos }
 }
 });
 }
 var clearPointPositions = function() {
 this.pointPositions = {};
 }
 // TODO: Probably a smarter way of making this reusable for both 'this.current'
 // and the pointsDup in buildPointPosn. Can't think of it now. 
 var hidePointWithId = function(pId, points) {
 var pointId = parseInt(pId), 
 points = points || this.current;
 return points.filter(function(p) {
 return pointId !== p.id;
 });
 }
 var filterInRange = function(range) {
 this.current = this.allPoints.filter(function(point, idx) {
 return point.withinRange(range);
 });
 }
 var addMultiPoints = function(yearsToAdd) {
 var self = this,
 mPoint;
 yearsToAdd = yearsToAdd.unique();
 yearsToAdd.forEach(function(year, idx) {
 mPoint = multiPoint(year);
 self.current.push(mPoint);
 self.pointPositions[mPoint.id] = { x: year, y: Hist.TL.config.maxOfStacked };
 });
 }
 var replaceMaxStacked = function() {
 var yearsToAddMultiPoint = [],
 positionKeys = Object.keys(this.pointPositions),
 self = this,
 xPos,
 yPos;
 positionKeys.forEach(function(pId, idx) {
 xPos = self.pointPositions[pId]['x'];
 yPos = self.pointPositions[pId]['y'];
 if (yPos >= Hist.TL.config.maxOfStacked) {
 yearsToAddMultiPoint.push(xPos);
 self.current = self.hidePointWithId(pId);
 }
 });
 // Now that we've remove the points which were stacked too high we can 
 // add back the multiPoints in their place.
 this.addMultiPoints(yearsToAddMultiPoint);
 }
 // Fields
 collection.allPoints = allPoints;
 collection.current = current;
 collection.pointPositions = pointPositions;
 // Methods
 collection.buildPointPositions = buildPointPositions;
 collection.clearPointPositions = clearPointPositions;
 collection.filterInRange = filterInRange;
 collection.hidePointWithId = hidePointWithId;
 collection.replaceMaxStacked = replaceMaxStacked;
 collection.addMultiPoints = addMultiPoints;
 return collection;
}
// Our Point object
Hist.TLO.timelinePoint = function(page) {
 var point = {};
 // This is the kind of code you have to write when people use a table to 
 // represent a simple string. Seriously though, da fuq!
 // TODO: I can do this simpler with an array.. doh. 
 var findType = function(categoryId) {
 switch (categoryId) {
 case 1:
 return 'person';
 case 2:
 return 'project';
 case 3:
 return 'organization';
 case 4:
 return 'event';
 default:
 return null;
 }
 };
 point.id = page['pk'];
 point.name = page['fields']['name'] || page['name'];
 point.vanityUrl = page['fields']['vanity_url'] || page['vanityUrl'];
 point.description = page['fields']['description'] || page['description'];
 point.date = moment(page['fields']['date_established']) || moment();
 point.type = findType(page['fields']['type']) || page['type'];
 point.pointImage = "/static/img/timeline/" + point.type + "-button.png";
 var toString = function() {
 return "Point -> id: " + this.id + " name: " + this.name + " date: " + this.date.format('l') + " type: " + this.type;
 };
 var isValid = function() {
 return this.type !== null && !!page['fields']['date_established'];
 };
 var approxDateYear = function(mod) {
 var year = this.date.year(),
 remainder = year % mod,
 halfMod = mod / 2;
 if (remainder <= halfMod) {
 return new Date(year - remainder, 0);
 } else {
 return new Date(year - remainder + halfMod, 0);
 }
 };
 var approxDateMonth = function(mod) {
 var month = this.date.month(),
 remainder = month % mod,
 halfMod = mod / 2;
 if (remainder <= halfMod) {
 return new Date(this.date.year(), month - remainder);
 } else {
 return new Date(this.date.year(), month - remainder + halfMod);
 }
 };
 var withinRange = function(range) {
 return this.date.isAfter(range.begin) && this.date.isBefore(range.end) ||
 this.date.isSame(range.begin) ||
 this.date.isSame(range.end);
 };
 var isSameMonthAsPoint = function(point) {
 return this.date.isSame(point.date, 'year') && this.date.isSame(point.date, 'month');
 }
 var isSameDayAsPoint = function(point) {
 return this.date.isSame(point.date, 'year') && this.date.isSame(point.date, 'month') && this.date.isSame(point.date, 'day');
 }
 point.toString = toString;
 point.isValid = isValid;
 point.withinRange = withinRange;
 point.isSameMonthAsPoint = isSameMonthAsPoint;
 point.isSameDayAsPoint = isSameDayAsPoint;
 point.approxDateYear = approxDateYear;
 point.approxDateMonth = approxDateMonth;
 return point;
}
Hist.TLO.multiPoint = function(year) {
 var pointDefaults = { name: "Multiple Available", vanityUrl: null,
 description: "Multiple Available", type: 'multi',
 fields: {} },
 point = Hist.TLO.timelinePoint(pointDefaults);
 point.id = Hist.TLUtils.generateRandomId();
 point.date = moment(new Date(year, 5));
 return point;
}
// Timeline
////////////
Hist.TL = (function() {
 var margin = {top: 90, right: 30, bottom: 90, left: 30},
 width = 960,
 height = 300,
 maxOfStacked = 4,
 pointSize = 25,
 yPosMargin = 30,
 pointClicked = false,
 timelinePoints,
 brush,
 xAxis,
 xScale,
 beginning,
 ending,
 chart,
 // Alias our TimelineUtils methods
 roundToDecade = Hist.TLUtils.roundToDecade,
 // Alias our Timeline Objects
 pointCollection = Hist.TLO.pointCollection,
 timelinePoint = Hist.TLO.timelinePoint,
 multiPoint = Hist.TLO.multiPoint;
 var initD3Chart = function() {
 var jsDates = timelinePoints.current.map(function(p) { return p.date.toDate(); });
 beginning = roundToDecade(d3.min(jsDates), true);
 ending = roundToDecade(d3.max(jsDates));
 chart = d3.select('#timeline')
 .attr('width', width)
 .attr('height', height)
 .append("g")
 .attr("transform", "translate(" + margin.left + ",0)");
 xScale = d3.time.scale()
 .nice(d3.time.year, 100)
 .domain([beginning, ending])
 .range([0, width - margin.right - margin.left]);
 xAxis = d3.svg.axis()
 .scale(xScale)
 .orient("bottom");
 chart.append("g")
 .attr("class", "x axis")
 .attr('transform', 'translate(0,' + (height - margin.bottom) + ')')
 .call(xAxis);
 timelinePoints.buildPointPositions(Hist.TLO.range(beginning, ending));
 // Replace the points which are stacked too high with multiPoints
 timelinePoints.replaceMaxStacked();
 var points = chart.selectAll(".timeline-point").data(timelinePoints.current);
 points.enter()
 .append("image")
 .attr("class", "timeline-point")
 .attr("id", function(p) { return 'point-' + p.id; })
 .attr("x", getXPosition)
 .attr("y", getYPosition)
 .attr("cx", getXPosition)
 .attr("cy", getYPosition)
 .attr("height", pointSize)
 .attr("width", pointSize)
 .attr("xlink:href", function(p) { return p.pointImage; })
 .on("mouseover", showActiveState)
 .on("mouseout", hideActiveState)
 .on("click", setClicked);
 initContextArea();
 }
 var draw = function(range) {
 var points;
 // Create out pointPositions object
 timelinePoints.clearPointPositions();
 timelinePoints.buildPointPositions(range);
 // Replace the points which are stacked too high with multiPoints
 timelinePoints.replaceMaxStacked();
 // Remove the current points
 chart.selectAll(".timeline-point").remove();
 // Set the newly filtered points as our new data
 points = chart.selectAll(".timeline-point").data(timelinePoints.current);
 points.enter()
 .append("image")
 .attr("class", "timeline-point")
 .attr("id", function(p) { return 'point-' + p.id; })
 .attr("x", getXPosition)
 .attr("y", getYPosition)
 .attr("cx", getXPosition)
 .attr("cy", getYPosition)
 .attr("height", pointSize)
 .attr("width", pointSize)
 .attr("xlink:href", function(p) { return p.pointImage; })
 .on("mouseover", showActiveState)
 .on("mouseout", hideActiveState)
 .on("click", setClicked);
 }
 // D3 Plotting Helpers
 ///////////////////////
 var getXPosition = function(point) {
 var date = timelinePoints.pointPositions[point.id]['x'];
 return xScale(date) - (pointSize / 2);
 }
 var getYPosition = function(point) {
 // height - bottom => xAxis line
 // xAxis line - yPosMargin => Starting yPos for a 0 count point
 // starting yPos - (yPos[id] * pointSize) => final yPosition
 return height - margin.bottom - yPosMargin - (pointSize * timelinePoints.pointPositions[point.id]['y']);
 }
 // SVG Brush Helpers
 /////////////////////
 var initContextArea = function() {
 var contextWidth = 600,
 contextHeight = 30,
 contextTickSize = 30,
 contextXAxis,
 contextXScale,
 contextArea,
 context;
 contextXScale = d3.time.scale()
 .range([0, contextWidth])
 .domain(xScale.domain());
 contextXAxis = d3.svg.axis()
 .scale(contextXScale)
 .tickSize(contextTickSize)
 .tickPadding(5)
 .orient("bottom");
 contextArea = d3.svg.area()
 .interpolate("monotone")
 .x(function(d) { return contextXScale(d); })
 .y0(contextHeight)
 .y1(0);
 brush = d3.svg.brush()
 .x(contextXScale)
 .extent([beginning, ending])
 .on("brushend", brushended);
 context = d3.select("#timeline").append("g")
 .attr("class", "context")
 .attr("transform", "translate(" + (width / 2 - contextWidth / 2) + "," + (height - margin.bottom + 25) + ")");
 context.append("g")
 .attr("class", "x axis")
 .attr("transform", "translate(0,0)")
 .call(contextXAxis);
 gBrush = context.append("g")
 .attr("class", "brush")
 .call(brush)
 .call(brush.event);
 gBrush.selectAll("rect")
 .attr('transform', 'translate(0,0)')
 .attr("height", contextTickSize);
 }
 var brushended = function() {
 var extent0 = brush.extent(),
 begin = extent0[0],
 end = extent0[1],
 range = Hist.TLO.range(begin, end);
 xScale.domain([begin, end]);
 xAxis.scale(xScale);
 chart.select(".x.axis").call(xAxis);
 timelinePoints.filterInRange(range);
 draw(range);
 }
 // Timeline Interaction Helpers
 ////////////////////////////////
 // TODO: Pull out to own module and merge with Hist.TL on init
 var initDomEventHandlers = function() {
 // Clicked away from a point handler, sets the state to inactive
 $("body").live("click", function(){
 var activePoint = $('#timeline').data('active-point'),
 activeEl;
 setUnclicked();
 if (activePoint) {
 activeEl = $('#point-' + activePoint.id)[0];
 hideActiveState.call(activeEl, activePoint);
 }
 });
 }
 var setClicked = function(point) {
 pointClicked = true;
 // Stop the event from bubbling up to body where we have a click handler to 
 // deactivate the current point. d3.event is the current event for this click
 d3.event.stopPropagation();
 }
 var setUnclicked = function() {
 pointClicked = false;
 }
 // Active State - Mousing over or clicked
 var showActiveImage = function(element, point) {
 var hoverImageUrl = point.pointImage.replace(/(.*)\.png/, "1ドル-hover.png");
 d3.select(element).attr("xlink:href", hoverImageUrl);
 }
 var addDescriptionToPoint = function(description) {
 if (description.length <= 200) {
 $('.regular-point .description').text(description);
 } else {
 $('.regular-point .description').text(description.substring(0, 200) + "...");
 }
 }
 var showPopup = function(element, point) {
 var d3Element = d3.select(element),
 leftPos = parseInt(d3Element.attr('x')),
 topPos = parseInt(d3Element.attr('y')),
 leftOffset,
 topOffset,
 popupLeft;
 // Hide both popups so we aren't showing both.
 $('.popup').hide();
 if (point.type !== 'multi') {
 // Setup the content now so we can grab the height and use it to calculate the topOffset
 $('.regular-point h3').text(point.name);
 addDescriptionToPoint(point.description);
 $('.regular-point .date').text(point.date.format("dddd, MMMM Do YYYY"));
 $('.regular-point a').attr('href', "/pages/" + point.vanityUrl);
 $('.regular-point').removeClass()
 .addClass(point.type)
 .addClass("popup")
 .addClass("regular-point")
 .show();
 } else {
 $('.multi-point').show();
 }
 popupHeight = $('#popup-container').height();
 leftOffset = (pointSize / 2);
 topOffset = (pointSize / 2) + popupHeight + 11; // +11 px is for padding I think..
 // Now that we have the offset we can find the absolute position of the popup
 popupLeft = leftPos + pointSize + leftOffset + 'px';
 popupTop = topPos + pointSize - topOffset + 'px';
 $('#popup-container').css({ left: popupLeft, top: popupTop }).show()
 }
 var showActiveState = function(point) {
 // We just moused into a point, clear the last clicked point (if any)
 setUnclicked();
 if ($('#timeline').data('active-point')) {
 // Passing null here as hideActiveImage will find the element from the given point.id
 hideActiveImage(null, $('#timeline').data('active-point'));
 }
 // Set the hover point image and configure/show the popup
 showActiveImage(this, point);
 showPopup(this, point);
 // Store the currently active point so we can deactive it later
 $('#timeline').data('active-point', point);
 }
 // Deactive State
 //////////////////
 var hideActiveImage = function(element, point) {
 // If we weren't passed the element then find it by the given point.id, otherwise select it
 d3El = element === null ? d3.select('#point-' + point.id) : d3.select(element);
 d3El.attr("xlink:href", point.pointImage);
 }
 var hidePopup = function() {
 $('#popup-container').hide();
 }
 var hideActiveState = function(point) {
 // If we are currently focusing on a point (have clicked it) then we don't 
 // want to hide the active state.
 if (!pointClicked) {
 hideActiveImage(this, point);
 hidePopup();
 }
 }
 // Public Interface
 ////////////////////
 return {
 init: function() {
 if (Hist.rawPages != null) {
 timelinePoints = pointCollection(Hist.rawPages);
 initD3Chart();
 initDomEventHandlers();
 }
 },
 config: {
 maxOfStacked: maxOfStacked
 }
 }
})();

jsFiddle

Here are the few things I'm looking to get feedback on:

  • pointCollection#buildPointPositions - This is the main issue I struggled with originally. It was previously running in \$O(n^2)\$ but I've gotten it down to \$O(n)\$ (I think) and it's much faster now. I'm still not extremely happy with it so critique away.

  • General JavaScript pattern/style

  • D3.js specific stuff. How could I have made this cooler? Could I have animated the timeline points moving positions somehow instead of removing everything and redrawing? Any comments on the D3 code in general? This is the first thing I've wrote with that library so I'm interested in that feedback.

asked Feb 14, 2014 at 17:24
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

For this

if (rangeDifference >= 80) {
 approximaterMod = 10;
 console.log("=========== range is 80+");
} else if (rangeDifference >= 60) {
 approximaterMod = 8;
 console.log("=========== range is 60+");
} else if (rangeDifference >= 45) {
 approximaterMod = 6;
 console.log("=========== range is 45+");
} else if (rangeDifference >= 30) {
 approximaterMod = 4;
 console.log("=========== range is 30+");
} else if (rangeDifference >= 20) {
 approximaterMod = 2;
 console.log("=========== range is 20+");
} else if (rangeDifference >= 10) {
 approximaterMod = 6; 
 console.log("=========== range is 10+");
} else if (rangeDifference >= 4) {
 approximaterMod = 2;
 console.log("=========== range is 4+");
} else {
 approximaterMod = null
 console.log("=========== range is 4-");
}

You could use a datastructure to store your values:

var ranges = [
 {limit: 80, mod: 10}, 
 {limit: 60, mod: 8}, 
 {limit: 45, mod: 6}, 
 {limit: 30, mod: 4}, 
 {limit: 20, mod: 2}, 
 {limit: 10, mod: 6}, 
 {limit: 4, mod: 2}, 
 {limit: null, mod: null}
 ];
for(var i = 0; i < ranges.length; i++){
 var range = ranges[i];
 if(rangeDifference >= range.limit || range.limit === null){
 approximaterMod = range.mod;
 if(range.limit !== null){
 console.log("=========== range is "+range.limit+"+");
 } else {
 console.log("=========== range is "+ranges[i-1].limit+"-");
 }
 break;
 }
}

By doing so, it's easier to see what difference maps to what mod value. It looks like a complex for loop at the moment, but that's because of the handling for your console logging... if we could take that out, it would look like so:

var ranges = [
 {limit: 80, mod: 10}, 
 {limit: 60, mod: 8}, 
 {limit: 45, mod: 6}, 
 {limit: 30, mod: 4}, 
 {limit: 20, mod: 2}, 
 {limit: 10, mod: 6}, 
 {limit: 4, mod: 2}
 ];
approximaterMod = null;
for(var i = 0; i < ranges.length; i++){
 var range = ranges[i];
 if(rangeDifference >= range.limit){
 approximaterMod = range.mod;
 break;
 }
}
answered Feb 25, 2016 at 13:12
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.