Skip to content

Instantly share code, notes, and snippets.

@cmdoptesc
Last active November 26, 2021 17:38
Show Gist options
  • Save cmdoptesc/6531299 to your computer and use it in GitHub Desktop.
Save cmdoptesc/6531299 to your computer and use it in GitHub Desktop.
D3: Open Hours (.csv parsing)

D3: Open Hours

view on bl.ocks.org
github repo

D3 visualisation utilising D3's text/CSV to read/parse a CSV file with restaurant information and display which restaurants are currently open.

Users can drag the time (red line) to display restaurants open at other hours.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>What's open now?</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="http://code.jquery.com/jquery-2.0.3.min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.5.1/underscore-min.js"></script>
<script src="http://cdnjs.cloudflare.com/ajax/libs/d3/3.2.2/d3.v3.min.js"></script>
<script src="openhours.js"></script>
<style>
#ChartSVG {
border: 1px solid rgba(153,153,153, 0.5);
}
line.rule {
stroke: #ccc;
}
line.current-time {
stroke: #f00;
stroke-width: 3px;
}
rect.current-clickoverlay {
fill: rgba(255,255,0, 0.01);
cursor: pointer;
}
.x-axis text {
font: 12px sans-serif;
}
.x-axis path,
.x-axis line {
fill: none;
stroke: #000;
shape-rendering: crispEdges;
}
text.restaurant-names {
font: 10px sans-serif;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<div id="ChartArea"></div>
</div>
<script src="openhours-d3.js"></script>
</body>
</html>
$(function() {
d3.select("#ChartArea").append('svg:svg')
.attr("id",'ChartSVG')
.attr("width", width)
.attr("height", height);
find_open_restaurants('rest_hours.csv', new Date(), function(openSpots) {
render(openSpots);
});
});
var margin = {top: 20, right: 20, bottom: 30, left: 40},
width = 960 - margin.left - margin.right,
height = 700 - margin.top - margin.bottom;
var d3methods = {
hr_offset: 4,
red_x: undefined
};
d3methods.key = function(d) {
return d.name;
};
d3methods.setY = function(d, i) {
return 'translate(0,'+ (((i+1)*12)+margin.top) +')';
};
d3methods.reverseScale = d3.scale.linear().domain([0, width]).range([d3methods.hr_offset, 29]);
d3methods.xScale = d3.scale.linear().domain([d3methods.hr_offset, 29]).range([0, width]),
d3methods.xValue = function(d) { return d3methods.xScale(d.close - (d.open-d3methods.hr_offset)); };
d3methods.dragmove = function(d) {
d3methods.red_x += d3.event.dx;
d3.select(this).attr("transform", "translate(" + d3methods.red_x + "," + (margin.top+1) + ")");
var hrs = d3methods.reverseScale(d3methods.red_x);
var tmp = new Date();
if(hrs >= 24) {
hrs -= 24;
tmp.setDate(tmp.getDate()-1);
}
var today = new Date(tmp.getFullYear(), tmp.getMonth(), tmp.getDate(), Math.floor(hrs), Math.floor((hrs%1)*60), 0, 0);
find_open_restaurants('rest_hours.csv', today, function(openSpots) {
redraw(openSpots);
});
};
var redraw = function(dataset) {
var vis = d3.select("#ChartSVG");
var gBar = vis.selectAll("g.bar-group");
gBar = gBar.data(dataset, d3methods.key);
gBar.exit().attr("opacity", 0.25)
.transition()
.duration(300)
.attr("transform", function(d, i) {
var x1 = d3methods.xScale(d.open);
var x2 = x1 + d3methods.xValue(d);
var translateX;
// to move left/right depending where the red line is
if(d3methods.red_x >= x2) {
translateX = -700;
} else if(d3methods.red_x <= x1) {
translateX = width + 700;
}
return 'translate('+ translateX +','+ this._y +')';
})
.remove();
gBar.attr("opacity", 0.75)
.transition()
.duration(250)
.attr("transform", d3methods.setY)
.each('end', function() {
d3.select(this).attr("opacity", 1);
var coordsRaw = d3.select(this).attr("transform");
var coordsRegex = /(\d+)/g;
if(coordsRaw) {
var coords = coordsRaw.match(coordsRegex);
this._y = coords[1];
}
});
var group = gBar.enter()
.append("svg:g")
.attr("class", 'bar-group')
.attr("transform", d3methods.setY);
group.append('text')
.attr("class", 'restaurant-names')
.attr("x", function(d){
return d3methods.xScale(d.open)-10;
})
.attr("y", 4)
.attr("text-anchor", "end")
.text(function(d){
return d.name;
});
group.append('rect')
.attr("class", 'rect-rest')
.attr("x", function(d){
return d3methods.xScale(d.open);
})
.attr("width", d3methods.xValue)
.attr("height", 4)
.on("mouseover", function() {
d3.select(this).transition()
.duration(100)
.attr("height", 6)
.attr("transform", "translate(0,-1)");
})
.on("mouseout", function() {
d3.select(this).transition()
.duration(100).attr('height', 4)
.attr("transform", "translate(0,0)");
});
// bring the current time group to the front again
var current = d3.select("g.current-time-group").node();
current.parentNode.appendChild(current);
}
var render = function(dataset) {
var vis = d3.select("#ChartSVG");
// x-axis
var xAxis = d3.svg.axis().scale(d3methods.xScale).orient("top").tickSize(0).tickFormat(function(d, i){
return (i>0) ? helpers.to12Hr(d%24) : '';
});
vis.append("g")
.attr("class", 'x-axis')
.attr("transform", "translate(0,20)")
.call(xAxis);
// rules
var x_rules = vis.append("g").attr("class", 'x-rules');
x_rules.selectAll("line.rule")
.data(d3methods.xScale.ticks(29))
.enter().append("line")
.attr("class", 'rule')
.attr("x1", d3methods.xScale)
.attr("x2", d3methods.xScale)
.attr("y1", margin.top)
.attr("y2", height);
// all the open restaurants
var gBar = vis.selectAll("g.bar-group");
gBar = gBar.data(dataset, d3methods.key);
var group = gBar.enter().append("svg:g")
.attr("class", 'bar-group')
.attr("transform", d3methods.setY);
group.append('text')
.attr("class", 'restaurant-names')
.attr("x", function(d){
return d3methods.xScale(d.open)-10;
})
.attr("y", 4)
.attr("text-anchor", "end")
.text(function(d){
return d.name;
});
group.append('rect')
.attr("class", 'rect-rest')
.attr("x", function(d){
return d3methods.xScale(d.open);
})
.attr("width", d3methods.xValue)
.attr("height", 4)
.on("mouseover", function() {
d3.select(this).transition()
.duration(100)
.attr("height", 6)
.attr("transform", "translate(0,-1)");
})
.on("mouseout", function() {
d3.select(this).transition()
.duration(100).attr('height', 4)
.attr("transform", "translate(0,0)");
});
var currentTime = new Date();
var rightNow = (currentTime.getHours() < helpers._cutoff) ? currentTime.getHours()+24 : currentTime.getHours();
rightNow += Math.round((currentTime.getMinutes()/60)*10000)/10000;
d3methods.red_x = d3methods.xScale(rightNow);
// group representing current time
var gCurrent = vis.append("svg:g")
.attr("class", 'current-time-group')
.attr("transform", 'translate('+ d3methods.red_x +','+ (margin.top+1) +')')
.call(d3.behavior.drag().on("drag", d3methods.dragmove));
// the red line
gCurrent.append("line")
.attr("class", 'current-time')
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", 0)
.attr("y2", height-margin.top-1);
// to make the click area bigger
gCurrent.append('rect')
.attr("class", 'current-clickoverlay')
.attr("x", -8)
.attr("y", 0)
.attr("width", 16)
.attr("height", height-margin.top-1)
.attr("opacity", 0);
};
// formerly helpers.js
var helpers = {
rangeToDays: function(startDay, endDay) {
var week = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
var days = [];
var i, open = false;
// loops twice just in case of a Sun-Thu schedule
for(i=0; i<week.length*2; i++) {
if(week[i%7] === startDay) { open = true; }
if(open) { days.push(week[i%7]); }
if(week[i%7] === endDay && open === true) { break; }
}
return days;
},
// converts time from 12-hour string into a 24-hr decimal
// midnight on the dot is treated as 24, but 12:15am is 0.25
to24Hr: function(twelveHour) {
var hoursRegex = /\d*/;
var eveningRegex = /pm/i;
var time = twelveHour.split(':');
var hours = parseInt(hoursRegex.exec(time[0])[0], 10);
if(eveningRegex.test(twelveHour) && hours < 12) {
hours += 12;
} else if (!eveningRegex.test(twelveHour) && hours === 12) {
if(!time[1] || parseInt(time[1], 10) === 0) {
hours = 24;
} else {
hours -= 12;
}
}
if(time[1]) {
hours += parseInt(time[1], 10)/60;
}
return hours;
},
to12Hr: function(twentyfour) {
twentyfour = parseFloat(twentyfour);
var min = Math.floor((twentyfour % 1) * 60);
if(min === 0) {
min = '';
} else {
min = min + '';
min = (min.length<2) ? ':0'+ min : ':'+ min;
}
var hr = Math.floor(twentyfour);
if(hr === 0 || hr === 24) {
hr = '12'+ min +' am';
} else if(hr === 12) {
hr = '12'+ min +' pm';
} else if(hr > 12) {
hr = hr%12 + min +' pm';
} else {
hr = hr + min +' am';
}
return hr;
},
// since the schedule considers early morning hours as the previous
// day, I've set 5am as the cutoff. times between midnight and 5am
// will reference the previous day's schedule
_cutoff: 5,
getDay: function(dateObj) {
var weekday = {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat'
};
var hour = parseInt(dateObj.getHours(), 10);
var day = weekday[dateObj.getDay()];
if(hour < helpers._cutoff) {
day = (day === 'Sun') ? 'Sat' : weekday[dateObj.getDay()-1];
}
return day;
},
getTime: function(dateObj) {
var time = parseInt(dateObj.getHours(), 10);
time += parseFloat(dateObj.getMinutes()/60);
return time;
}
};
var makeSchedule = function(rawSchedule) {
var schedule = init(rawSchedule);
function _parseHours(rawHours) {
var hoursRegex = /\d*:*\d+ [ap]m - \d*:*\d+ [ap]m/;
var openclose = rawHours.match(hoursRegex)[0].split(' - ');
openclose[0] = helpers.to24Hr(openclose[0]);
openclose[1] = helpers.to24Hr(openclose[1]);
return openclose;
}
function _parseDays(rawDays) {
var openDays = []; // array of days sharing the same schedule
var dayRangeRegex = /[a-z]{3}-[a-z]{3}/i;
if(rawDays.match(dayRangeRegex) && rawDays.match(dayRangeRegex).length > 0) {
var dayRange = rawDays.match(dayRangeRegex)[0].split('-');
openDays = helpers.rangeToDays(dayRange[0], dayRange[1]);
}
var singleDaysRegex = /([a-zA-Z]{3})/g;
var singleDays = rawDays.match(singleDaysRegex);
_.each(singleDays, function(day) {
openDays.push(day);
});
return openDays;
}
function init(rawSched) {
var parsed = {};
var scheds = rawSched.split('/');
_.each(scheds, function(sched) {
sched = sched.trim();
var openclose = _parseHours(sched);
var days = _parseDays(sched);
_.each(days, function(day){
parsed[day] = {};
parsed[day].open = openclose[0];
parsed[day].close = openclose[1];
});
});
return parsed;
}
return schedule;
};
// formerly restaurant.js
// wrote in pseudo-classical style since I didn't want all instances
// of Restaurant to have their own instance of isOpen
var Restaurant = function(name, rawHours) {
this.name = name;
this.schedule = makeSchedule(rawHours);
};
// returns true if it's open for the time, false if not
Restaurant.prototype.isOpen = function(dateObj) {
var time = helpers.getTime(dateObj);
var day = helpers.getDay(dateObj);
if(typeof this.schedule[day] === 'undefined') { return false; }
var open = this.schedule[day].open;
var close = this.schedule[day].close;
if(open < close && open <= time && time < close) {
return true;
} else if(open > close) { // if it rolls over to the next day (e.g. 1800 - 0200)
if( (open <= time && time <= 24) || (0 <= time && time < close) ) {
return true;
}
}
return false;
};
var parseCsvMaker = function() {
var cache = {};
return function(filename, callback) {
if(cache[filename]) {
return (callback) ? callback(cache[filename]) : cache[filename];
} else {
d3.text(filename, function(err, csvData) {
var data = d3.csv.parseRows(csvData);
var restaurants = [];
for(var i=0; i<data.length; i++) {
var rest = new Restaurant(data[i][0], data[i][1]);
restaurants.push(rest);
}
cache[filename] = restaurants;
return (callback) ? callback(restaurants) : restaurants;
});
};
};
};
var parseCSV = parseCsvMaker();
var find_open_restaurants = function(csv_filepath, dateObj, callback) {
parseCSV(csv_filepath, function(restaurants) {
var openSpots = [];
var day = helpers.getDay(dateObj);
var spot = {};
for(var i=0; i<restaurants.length; i++) {
if(restaurants[i].isOpen(dateObj)) {
spot = {
name: restaurants[i].name,
open: restaurants[i].schedule[day].open,
close: restaurants[i].schedule[day].close
};
if(spot.close < helpers._cutoff) {
spot.close += 24; // modified data for D3.. really should be on D3 end
}
openSpots.push(spot);
}
}
openSpots = _.sortBy(openSpots, function(spot) {
return spot.name;
});
return (callback) ? callback(openSpots) : openSpots;
});
};
Kushi Tsuru Mon-Sun 11:30 am - 9 pm
Osakaya Restaurant Mon-Thu, Sun 11:30 am - 9 pm / Fri-Sat 11:30 am - 9:30 pm
The Stinking Rose Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm
McCormick & Kuleto's Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm
Mifune Restaurant Mon-Sun 11 am - 10 pm
The Cheesecake Factory Mon-Thu 11 am - 11 pm / Fri-Sat 11 am - 12:30 am / Sun 10 am - 11 pm
New Delhi Indian Restaurant Mon-Sat 11:30 am - 10 pm / Sun 5:30 pm - 10 pm
Iroha Restaurant Mon-Thu, Sun 11:30 am - 9:30 pm / Fri-Sat 11:30 am - 10 pm
Rose Pistola Mon-Thu 11:30 am - 10 pm / Fri-Sun 11:30 am - 11 pm
Alioto's Restaurant Mon-Sun 11 am - 11 pm
Canton Seafood & Dim Sum Restaurant Mon-Fri 10:30 am - 9:30 pm / Sat-Sun 10 am - 9:30 pm
All Season Restaurant Mon-Fri 10 am - 9:30 pm / Sat-Sun 9:30 am - 9:30 pm
Bombay Indian Restaurant Mon-Sun 11:30 am - 10:30 pm
Sam's Grill & Seafood Restaurant Mon-Fri 11 am - 9 pm / Sat 5 pm - 9 pm
2G Japanese Brasserie Mon-Thu, Sun 11 am - 10 pm / Fri-Sat 11 am - 11 pm
Restaurant Lulu Mon-Thu, Sun 11:30 am - 9 pm / Fri-Sat 11:30 am - 10 pm
Sudachi Mon-Wed 5 pm - 12:30 am / Thu-Fri 5 pm - 1:30 am / Sat 3 pm - 1:30 am / Sun 3 pm - 11:30 pm
Hanuri Mon-Sun 11 am - 12 am
Herbivore Mon-Thu, Sun 9 am - 10 pm / Fri-Sat 9 am - 11 pm
Penang Garden Mon-Thu 11 am - 10 pm / Fri-Sat 10 am - 10:30 pm / Sun 11 am - 11 pm
John's Grill Mon-Sat 11 am - 10 pm / Sun 12 pm - 10 pm
Quan Bac Mon-Sun 11 am - 10 pm
Bamboo Restaurant Mon-Sat 11 am - 12 am / Sun 12 pm - 12 am
Burger Bar Mon-Thu, Sun 11 am - 10 pm / Fri-Sat 11 am - 12 am
Blu Restaurant Mon-Fri 11:30 am - 10 pm / Sat-Sun 7 am - 3 pm
Naan 'N' Curry Mon-Sun 11 am - 4 am
Shanghai China Restaurant Mon-Sun 11 am - 9:30 pm
Tres Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 11 pm
Isobune Sushi Mon-Sun 11:30 am - 9:30 pm
Viva Pizza Restaurant Mon-Sun 11 am - 12 am
Far East Cafe Mon-Sun 11:30 am - 10 pm
Parallel 37 Mon-Sun 11:30 am - 10 pm
Bai Thong Thai Cuisine Mon-Sat 11 am - 11 pm / Sun 11 am - 10 pm
Alhamra Mon-Sun 11 am - 11 pm
A-1 Cafe Restaurant Mon, Wed-Sun 11 am - 10 pm
Nick's Lighthouse Mon-Sun 11 am - 10:30 pm
Paragon Restaurant & Bar Mon-Fri 11:30 am - 10 pm / Sat 5:30 pm - 10 pm
Chili Lemon Garlic Mon-Fri 11 am - 10 pm / Sat-Sun 5 pm - 10 pm
Bow Hon Restaurant Mon-Sun 11 am - 10:30 pm
San Dong House Mon-Sun 11 am - 11 pm
Thai Stick Restaurant Mon-Sun 11 am - 1 am
Cesario's Mon-Thu, Sun 11:30 am - 10 pm / Fri-Sat 11:30 am - 10:30 pm
Colombini Italian Cafe Bistro Mon-Fri 12 pm - 10 pm / Sat-Sun 5 pm - 10 pm
Sabella & La Torre Mon-Thu, Sun 10 am - 10:30 pm / Fri-Sat 10 am - 12:30 am
Soluna Cafe and Lounge Mon-Fri 11:30 am - 10 pm / Sat 5 pm - 10 pm
Tong Palace Mon-Fri 9 am - 9:30 pm / Sat-Sun 9 am - 10 pm
India Garden Restaurant Mon-Sun 10 am - 11 pm
Sapporo-Ya Japanese Restaurant Mon-Sat 11 am - 11 pm / Sun 11 am - 10:30 pm
Santorini's Mediterranean Cuisine Mon-Sun 8 am - 10:30 pm
Kyoto Sushi Mon-Thu 11 am - 10:30 pm / Fri 11 am - 11 pm / Sat 11:30 am - 11 pm / Sun 4:30 pm - 10:30 pm
Marrakech Moroccan Restaurant Mon-Sun 5:30 pm - 2 am
Katana-ya Mon-Sun 11:30 am - 1:30 am
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment