A couple months ago, I
pontificated at length about quitting smoking and working on my chinup game. There was a combo chart mentioned, and collected data. It is this combo chart that we will build today, in D3. The data is in this
CSV file.
This is going to be a bare bones chart, no fancy animations and whatnot. To that end, let's begin with the HTML and D3 import. You will see that in the HTML, I have also included a h1 tag, an SVG tag and a script tag.
<!DOCTYPE html>
<html>
<head>
<title>Quit Smoking!</title>
<style>
</style>
<script src="https://d3js.org/d3.v4.min.js"></script>
</head>
<body>
<h1>The Chinups - Cigarettes Combo Chart</h1>
<svg>
</svg>
<script>
</script>
</body>
</html>
In the CSS, we set a width and height for the SVG, and some specs for the h1 tag. The specs for the SVG are more pertinent to this tutorial.
<style>
svg
{
width: 800px;
height: 500px;
}
h1
{
font-family: verdana;
font-size: 16px;
}
</style>
That really is it for the HTML. From this point on, it will be mostly about the JavaScript. In the script tag, we have a
comboChart object. Aside from that, we also make a call to
d3's
csv() method, passing in the name of the file,
combo.csv, and a callback. We're going to be developing these two blocks somewhat concurrently.
<script>
let comboChart =
{
};
d3.csv("combo.csv", function(data)
{
});
</script>
In
comboChart, we have properties
cigData and
chinupData, which are arrays. We then have
maxUnits and
dataPoints, which are integers with a default value of 1.
<script>
let comboChart =
{
cigData: [],
chinupData: [],
maxUnits: 1,
dataPoints: 1
};
d3.csv("combo.csv", function(data)
{
});
</script>
When parsing
combo.csv, the
dataPoints property is set to the length of data.
<script>
let comboChart =
{
cigData: [],
chinupData: [],
maxUnits: 1,
dataPoints: 1
};
d3.csv("combo.csv", function(data)
{
comboChart.dataPoints = data.length;
});
</script>
We then have a
For loop to iterate through data. The figure in the Reps column gets pushed into the
chinupData array. The figure in the Cigs column gets pushed into the
cigData array.
<script>
let comboChart =
{
cigData: [],
chinupData: [],
maxUnits: 1,
dataPoints: 1
};
d3.csv("combo.csv", function(data)
{
comboChart.dataPoints = data.length;
for (var i = 0; i < data.length; i++)
{
comboChart.chinupData.push(parseInt(data[i].Reps));
comboChart.cigData.push(parseInt(data[i].Cigs));
}
});
</script>
We then define
maxReps. Using the
max() method of
d3, we want
maxReps to contain the maximum value in
chinupData. We then do the same for
maxCigs, using it to contain the maximum value in
cigData.
d3.csv("combo.csv", function(data)
{
comboChart.dataPoints = data.length;
for (var i = 0; i < data.length; i++)
{
comboChart.chinupData.push(parseInt(data[i].Reps));
comboChart.cigData.push(parseInt(data[i].Cigs));
}
var maxReps = d3.max(comboChart.chinupData);
var maxCigs = d3.max(comboChart.cigData);
});
And then we set
maxUnits to
maxReps or
maxCigs, whichever value is higher.
d3.csv("combo.csv", function(data)
{
comboChart.dataPoints = data.length;
for (var i = 0; i < data.length; i++)
{
comboChart.chinupData.push(parseInt(data[i].Reps));
comboChart.cigData.push(parseInt(data[i].Cigs));
}
var maxReps = d3.max(comboChart.chinupData);
var maxCigs = d3.max(comboChart.cigData);
if (comboChart.maxUnits < maxReps) comboChart.maxUnits = maxReps;
if (comboChart.maxUnits < maxCigs) comboChart.maxUnits = maxCigs;
});
Finally, we call the
drawCharts() method from the
comboChart object.
d3.csv("combo.csv", function(data)
{
comboChart.dataPoints = data.length;
for (var i = 0; i < data.length; i++)
{
comboChart.chinupData.push(parseInt(data[i].Reps));
comboChart.cigData.push(parseInt(data[i].Cigs));
}
var maxReps = d3.max(comboChart.chinupData);
var maxCigs = d3.max(comboChart.cigData);
if (comboChart.maxUnits < maxReps) comboChart.maxUnits = maxReps;
if (comboChart.maxUnits < maxCigs) comboChart.maxUnits = maxCigs;
comboChart.drawCharts();
});
We've not created
drawCharts() yet. Well, no time like the present! In it, use the
select() method of
d3 to get the sole SVG object, and set it to the variable
svgChart. Then clear
svgChart.
let comboChart =
{
cigData: [],
chinupData: [],
maxUnits: 1,
dataPoints: 1,
drawCharts: function()
{
var svgChart = d3.select("svg");
svgChart.html("");
}
}
Next, run the
drawAxes() method, passing in
svgChart as an argument.
let comboChart =
{
cigData: [],
chinupData: [],
maxUnits: 1,
dataPoints: 1,
drawCharts: function()
{
var svgChart = d3.select("svg");
svgChart.html("");
this.drawAxes(svgChart);
}
}
We'll want to create this method. It has a parameter,
chart. In
drawCharts(),
chart was already passed when
drawAxes() was called.
drawCharts: function()
{
var svgChart = d3.select("svg");
svgChart.html("");
this.drawAxes(svgChart);
},
drawAxes: function(chart)
{
}
We append one horizontal line and one vertical line. Bearing in mind that we have a 800 by 500 pixel square, and we want a 50 pixel buffer around the perimeter, this is how the
x1,
x2,
y1 and
y2 attributes are calculated. We use the CSS classes
axesVertical and
axesHorizontal.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
}
These two CSS classes described a thin
grey line.
svg
{
width: 800px;
height: 500px;
}
h1
{
font-family: verdana;
font-size: 16px;
}
.axesVertical, .axesHorizontal
{
stroke: rgba(100, 100, 100, 1);
stroke-width: 1px;
}
Here be your starting lines.
We then want to determine the amount of pixels dedicated to each point plotted on the horizontal and vertical axes. Since we have already determined
maxUnits (the highest value in the entire dataset) and
dataPoints (the total number of data rows in the entire dataset), we can define
pxPerUnit as available vertical space (500 - 50 - 50 = 400) divided by
maxUnits and
pxPerPoint as (800 - 50 - 50 = 700) divided by
dataPoints. And since we only want whole numbers, we'll use the
floor() method on these results.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
}
We then define
scale as an empty array. We will populate
scale with the y-positions we want on the vertical axis. Remember that we start from 450 and end with 50 because we're implementing a 50 pixel buffer on the maximum heigh of 500 pixels. And we do in in steps of
pxPerUnit.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
}
And then we go through chart to append line tags that are styled using the CSS class
scaleTick. These will be about 10 pixels in length. The data used will be the
scale array, which we've already populated with the appropriated values.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
chart.selectAll("line.scaleTick")
.data(scale)
.enter()
.append("line")
.attr("class", "scaleTick")
.attr("x1", "40px")
.attr("y1", function(d)
{
return d + "px";
})
.attr("x2", "50px")
.attr("y2", function(d)
{
return d + "px";
});
}
We'll add
scaleTick to this CSS specification.
.scaleTick, .axesVertical, .axesHorizontal
{
stroke: rgba(100, 100, 100, 1);
stroke-width: 1px;
}
And the ticks appear.
Then of course, we're going to need text. We will insert text tags, styled using the
scaleText CSS class. The data used will be
scale again, and the
y attribute will depend on the current element of
scale. The text itself, will reflect what the current index of
scale is. And this, not-so-coincidentally, will be the value that the specific tick on the axis represents!
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
chart.selectAll("line.scaleTick")
.data(scale)
.enter()
.append("line")
.attr("class", "scaleTick")
.attr("x1", "40px")
.attr("y1", function(d)
{
return d + "px";
})
.attr("x2", "50px")
.attr("y2", function(d)
{
return d + "px";
});
chart.selectAll("text.scaleText")
.data(scale)
.enter()
.append("text")
.attr("class", "scaleText")
.attr("x", "30px")
.attr("y", function(d)
{
return d + "px";
})
.text(
function(d, i)
{
return i;
});
}
In the CSS,
scaleText is defined like this.
.scaleTick, .axesVertical, .axesHorizontal
{
stroke: rgba(100, 100, 100, 1);
stroke-width: 1px;
}
.scaleText
{
font: 8px verdana;
fill: rgba(100, 100, 100, 1);
text-anchor: end;
}
Here, the text appears. Note that your maximum value is 10!
Now we want to plot data points along the horizontal axes. To that end, we create the
axes array. Then we populate it in a way similar to what we did for scale, instead using 750 and 50 as the end points in the
For loop, and
pxPerPoint as the decrementor.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
chart.selectAll("line.scaleTick")
.data(scale)
.enter()
.append("line")
.attr("class", "scaleTick")
.attr("x1", "40px")
.attr("y1", function(d)
{
return d + "px";
})
.attr("x2", "50px")
.attr("y2", function(d)
{
return d + "px";
});
chart.selectAll("text.scaleText")
.data(scale)
.enter()
.append("text")
.attr("class", "scaleText")
.attr("x", "30px")
.attr("y", function(d)
{
return d + "px";
})
.text(
function(d, i)
{
return i;
});
var axes = [];
for (var i = 750; i >= 50; i -= pxPerPoint)
{
axes.push(i);
}
}
Then we insert a bunch of 10 pixel vertical lines, styled using
axesTick, into the chart. The data used is
axes.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
chart.selectAll("line.scaleTick")
.data(scale)
.enter()
.append("line")
.attr("class", "scaleTick")
.attr("x1", "40px")
.attr("y1", function(d)
{
return d + "px";
})
.attr("x2", "50px")
.attr("y2", function(d)
{
return d + "px";
});
chart.selectAll("text.scaleText")
.data(scale)
.enter()
.append("text")
.attr("class", "scaleText")
.attr("x", "30px")
.attr("y", function(d)
{
return d + "px";
})
.text(
function(d, i)
{
return i;
});
var axes = [];
for (var i = 750; i >= 50; i -= pxPerPoint)
{
axes.push(i);
}
chart.selectAll("line.axesTick")
.data(axes)
.enter()
.append("line")
.attr("class", "axesTick")
.attr("x1", function(d)
{
return d + "px";
})
.attr("y1", "450px")
.attr("x2", function(d)
{
return d + "px";
})
.attr("y2", "460px");
}
We'll add
axesTick to this CSS specification.
.scaleTick, .axesTick, .axesVertical, .axesHorizontal
{
stroke: rgba(100, 100, 100, 1);
stroke-width: 1px;
}
And the horizontal axis is populated with ticks.
At the end of this method, we will run the methods
drawLines() and
drawBars(), passing in
chart,
pxPerUnit and
pxPerPoint as arguments.
drawAxes: function(chart)
{
chart
.append("line")
.attr("class", "axesVertical")
.attr("x1", "50px")
.attr("y1", "50px")
.attr("x2", "50px")
.attr("y2", "450px");
chart
.append("line")
.attr("class", "axesHorizontal")
.attr("x1", "50px")
.attr("y1", "450px")
.attr("x2", "750px")
.attr("y2", "450px");
var pxPerUnit = Math.floor(400 / this.maxUnits);
var pxPerPoint = Math.floor(700 / this.dataPoints);
var scale = [];
for (var i = 450; i >= 50; i -= pxPerUnit)
{
scale.push(i);
}
chart.selectAll("line.scaleTick")
.data(scale)
.enter()
.append("line")
.attr("class", "scaleTick")
.attr("x1", "40px")
.attr("y1", function(d)
{
return d + "px";
})
.attr("x2", "50px")
.attr("y2", function(d)
{
return d + "px";
});
chart.selectAll("text.scaleText")
.data(scale)
.enter()
.append("text")
.attr("class", "scaleText")
.attr("x", "30px")
.attr("y", function(d)
{
return d + "px";
})
.text(
function(d, i)
{
return i;
});
var axes = [];
for (var i = 750; i >= 50; i -= pxPerPoint)
{
axes.push(i);
}
chart.selectAll("line.axesTick")
.data(axes)
.enter()
.append("line")
.attr("class", "axesTick")
.attr("x1", function(d)
{
return d + "px";
})
.attr("y1", "450px")
.attr("x2", function(d)
{
return d + "px";
})
.attr("y2", "460px");
this.drawBars(chart, pxPerUnit, pxPerPoint);
this.drawLines(chart, pxPerUnit, pxPerPoint);
}
Of course, we will need to create these methods.
this.drawBars(chart, pxPerUnit, pxPerPoint);
this.drawLines(chart, pxPerUnit, pxPerPoint);
},
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
},
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
}
Let's begin with
drawLines(). We want to use the line portion of the combo chart to represent the smoking data. Thus, we declare
cigData and set it to the value of the
cigData array.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
},
Now, in
chart, we want to append line tags.
cigData is the dataset we will use. The CSS class used for this is
lineChart.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart");
},
In the CSS,
lineChart is a two pixel
red line.
.scaleText
{
font: 8px verdana;
fill: rgba(100, 100, 100, 1);
text-anchor: end;
}
.lineChart
{
stroke: rgba(255, 0, 0, 1);
stroke-width: 2px;
}
We set
x1 like this. First, we define the variable
val which is the index of the dataset,
i. If it is currently not the first element in the dataset (i.e,
i is greater than 0), then we decrement
val.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
});
},
And then we set
x1 to val multiplied by
pxPerPoint, plus 50 for the buffer. In effect, if it's the first element in the dataset,
x1 does pretty much nothing because it's at 0. If not, it uses the previous position in the dataset.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
return ((val * pxPerPoint) + 50) + "px";
});
},
x2, of course, is the current index,
i, multipled by
pxPerPoint and plus 50.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
return ((val * pxPerPoint) + 50) + "px";
})
.attr("x2", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
});
},
For
y1, we do something similar to what we did for
x1. Except that we initially set
val to the current value of the dataset instead of the index. And if it's not the first element in the dataset, we set
val to the previous value of
cigData.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
return ((val * pxPerPoint) + 50) + "px";
})
.attr("x2", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
})
.attr("y1", function(d, i)
{
var val = d;
if (i > 0)
{
val = cigData[i - 1];
}
});
},
And then we set
y1 by taking 450, which is position 0 on the vertical axis, and subtracting the product of
val and
pxPerUnit from it.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
return ((val * pxPerPoint) + 50) + "px";
})
.attr("x2", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
})
.attr("y1", function(d, i)
{
var val = d;
if (i > 0)
{
val = cigData[i - 1];
}
return (450 - (val * pxPerUnit)) + "px";
});
},
And we set y2 similarly, except we just use
d in a more straightforward way.
drawLines: function(chart, pxPerUnit, pxPerPoint)
{
var cigData = this.cigData;
chart.selectAll("line.lineChart")
.data(cigData)
.enter()
.append("line")
.attr("class", "lineChart")
.attr("x1", function(d, i)
{
var val = i;
if (i > 0)
{
val = val - 1;
}
return ((val * pxPerPoint) + 50) + "px";
})
.attr("x2", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
})
.attr("y1", function(d, i)
{
var val = d;
if (i > 0)
{
val = cigData[i - 1];
}
return (450 - (val * pxPerUnit)) + "px";
})
.attr("y2", function(d)
{
return (450 - (d * pxPerUnit)) + "px";
});
},
You see that
red line? That shows me starting the programme at 10 cigarettes a day, then moving my way down to 5 gradually over the next few months, and finally dropping to 0.
The next part, drawing the bar chart portion of the combo chart, is simple by comparison. We begin by declaring
chinupData and setting it to the value of the
chinupData array.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
}
In chart, we append rect tags that are styled using the CSS class
barChart. The data used for this is
chinupData.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
chart.selectAll("rect.barChart")
.data(chinupData)
.enter()
.append("rect")
.attr("class", "barChart");
In the CSS, the
barChart CSS class has an
orange background.
.lineChart
{
stroke: rgba(255, 0, 0, 1);
stroke-width: 2px;
}
.barChart
{
fill: rgba(255, 200, 100, 1);
stroke-width: 0px;
}
Let's do the easy part first. Every bar has the same width -
pxPerPoint pixels.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
chart.selectAll("rect.barChart")
.data(chinupData)
.enter()
.append("rect")
.attr("class", "barChart")
.attr("width", pxPerPoint + "px");
}
The height depends on the value of the current element in
chinupData,
d. We multiply
d by
pxPerUnit to get the height.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
chart.selectAll("rect.barChart")
.data(chinupData)
.enter()
.append("rect")
.attr("class", "barChart")
.attr("width", pxPerPoint + "px")
.attr("height", function(d)
{
return (d * pxPerUnit) + "px";
});
}
The
x attribute depends on the index,
i, of the current element. We multiply it by
pxPerPoint and add 50 for the horizontal buffer.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
chart.selectAll("rect.barChart")
.data(chinupData)
.enter()
.append("rect")
.attr("class", "barChart")
.attr("x", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
})
.attr("width", pxPerPoint + "px")
.attr("height", function(d)
{
return (d * pxPerUnit) + "px";
});
}
For
y, we take 450 (which is position 0 on the vertical axis) and subtract from it the product of
d and
pxPerUnit. In essence, we subtract the height of the bar from 450 to get the starting y-position of the rect tag.
drawBars: function(chart, pxPerUnit, pxPerPoint)
{
var chinupData = this.chinupData;
chart.selectAll("rect.barChart")
.data(chinupData)
.enter()
.append("rect")
.attr("class", "barChart")
.attr("x", function(d, i)
{
return ((i * pxPerPoint) + 50) + "px";
})
.attr("y", function(d)
{
return (450 - (d * pxPerUnit)) + "px";
})
.attr("width", pxPerPoint + "px")
.attr("height", function(d)
{
return (d * pxPerUnit) + "px";
});
}
And there's the
orange bar chart. You can see where I started off at 1 chinup, then shot up to 3 within a week, and how I gradually worked my way up from there.
There, all done!
This was simple in comparison to all the stuff we've gone through before with D3. That is a deliberate choice on my part - to give you the Combo Chart without throwing too much stuff in.
Also - and yes, I just wanna brag here - I'm three days away from making it an entire year without a cigarette. Go, me!
Cig-nificantly yours,
T___T