Desert RATS 2020 Cancelled
4 March 2020Oh, rats!
Due to concerns about the spread of COVID-19, the 2020 edition of the Desert RATS Classic is cancelled.
Oh, rats!
Due to concerns about the spread of COVID-19, the 2020 edition of the Desert RATS Classic is cancelled.
Problems solved. Well, some of them, at least.
Last Sunday, I took the bike out for a shakedown ride on some trails around Navajo Rocks. During the ride, I identified a list of things to address. I addressed some of the issues and went for another ride on it yesterday.
Here's where we stand:
I'm hoping to get out on this bike again tomorrow or Friday.
Well, I didn't die this time.
Yesterday, I finished reassembling the bicycle after powder coating; this afternoon I took it out for a shakedown ride around the 17-mile main loop at the Navajo Rocks area near Moab, Utah.
Overall things went pretty well, but a few things need attention:
I'll try to address these issues in the next day or two and then give it another go.
Unlike Humpty Dumpty, it's put back together.
Yesterday, I got the frame back from the powder coating shop and I began reassembly by installing a new headset. Today, I reassembled the rest of the bike. Most of these components were already on the bike before powder-coating, so it all went back together without any trouble.
Here's a summary of the components I have on the bike at this time:
Component | Description |
---|---|
Frame | 2013 Trek Marlin, aluminum, 19" size |
Fork | Manitou Markhor, 120mm travel |
Headset | Cane Creek Viscoset upper and 40-Series lower |
Drivetrain | SRAM NX Eagle, 34T chainring, 11-50 12-speed cassette |
Pedals | Crankbrothers Double Shot 1 |
Wheels | Novatec D481SBT/D482TSBT hubs, WTB ST i25 rims |
Tires | Continental RaceKing ShieldWall 29x2.2, Muc-Off sealant |
Handlebar | FIFTY-FIFTY 35mm riser bar, cut to 740mm width |
Stem | Race Face Respond, 60mm length, 10° rise |
Grips | ESI Chunky silicone MTB grips |
Brakes | Shimano Deore M6000, 180mm/160mm |
Seatpost | Brand-X Ascend II 105mm dropper seatpost |
I've ridden the bike a lot with most of these components, but the handlebars and pedals are new—tomorrow afternoon I've got a ride in mind to see how everything feels.
Happy Valentine's Day!
As I wrote in my previous post, I'm having my bicycle frame refinished. This is purely cosmetic; the paint on the frame was pretty scratched up and worn through to the metal in a few places. Powder coating will give the frame a nice durable finish.
I dropped off the frame at Pro Powder Coating earlier this week to have it sandblasted and powder coated in satin black, and this afternoon I got a call that it was finished.
It came out great; the folks at Pro Powder Coating know what they're doing and it shows. Everything was masked properly and I didn't have to sand off anything or chase any threads. Overall, it was well worth the relatively low cost compared to the time and tedium it would take me to properly sand and repaint the frame myself. And I hate painting.
I also installed a new headset into the frame. The headset is basically the bearings that support the front fork securely in the frame. I chose to use a Cane Creek Viscoset headset.
The Viscoset provides a small amount of damping to steering inputs in order to help make the bike more stable, especially targeting uncontrolled oscillations, or "speed wobble", that can pop up at higher speeds. Although the Viscoset was originally designed for use with e-bikes, it turns out to be well-suited for all kinds of bikes. Bikepacking.com has a great in-depth review of the Viscoset.
Tomorrow I'll assemble the rest of the components onto the frame and get it ready for a nice Sunday afternoon ride.
Let's lose those cable stops.
For purely cosmetic reasons, I'm having the bike frame sandblasted and powder coated next week. But before it goes off to the shop, I'm reaming out the cable stops on the frame.
Many bikes used to have mostly exposed shifter cables. This does save a little weight (and probably cost), but leaves the cables exposed to the elements. The older 8-speed rear derailleur that was on the bike was quite tolerant of some extra cable friction, but not so the newer 12-speed rear derailleur that I will be using.
By reaming out the cable stops, I can use a continuous length of cable housing from the shifter to the derailleur. This will better keep the cable protected from grit and corrosion and the derailleur shifting precisely. I used a small power rotary tool, but some small files can also get the job done—the frame is made of aluminum.
Here you can see one of the enlarged cable stops and how the cable housing fits right through it now.
Now the frame is ready for powder coating.
I purchased this Trek Marlin—an entry-level hardtail mountain bike—from my local bike shop back in 2013. A few years back, I replaced the flat handlebars with a cheap riser bar, and the long original stem with a short stem in order to make the bike more comfortable for me to ride around town.
Since starting to ride regularly again last summer, I've replaced just about all of the components on the bike, upgrading to modern mountain bike components—the only original part left is the frame. I've mostly been riding this bike on the roads and bike paths with a set of gravel tires so far this winter.
Now the frame has been refinished and the bike reassembled with a set of XC mountain bike tires, so I can prepare myself and the bike for the Desert RATS Classic mountain bike race in May 2020.
What have I gotten myself into?
I grew up riding a bicycle as a child. I vividly remember the first day I got my own 10-speed mountain bike for my birthday: it didn't go well. I ran into a big dog and went over the bars into the ditch on the side of the road. From that point, however, things improved.
I frequently rode my bike to school, and in the summer I would ride all over to meet up with my friends. We would take family day trips to some local rail trails to spend the day riding down by the ocean. But after high school, I stopped riding regularly.
Fast forward 17 years: last June my wife and I decided to take up mountain biking. Living in Grand Junction, Colorado, there are many trails nearby, so it was pretty easy to get out a few times a week and work up a sweat. Of course, summers are really hot, so I didn't start riding regularly until late September.
By now, both my fitness level and bike handling have improved a lot—easy when you start from zero—and I figured it was time to set a goal. So on May 9th, 2020, I'll be competing in the Desert RATS Classic mountain bike race.
The Desert RATS Classic is a 50 kilometer (31-mile) course run on some local roads and trails (primarily the Edge Loop and Sarlacc) that involves a lot of climbing. There's a 5-hour time limit, so I'll need to average about 6.2 mph in order to be an official finisher.
I've never been a particularly athletic person, so I have a lot of work to do. Over the next few months, I'll be posting about my bike setup, my training and fitness, and fun rides.
The scripting language used inside of SampleManager, VGL, has basic multidimensional array datatypes for fixed-length and variable-length arrays. In addition to the usual array accessor, there are some routines in the core STD_ARRAY
library to do some basic manipulation of arrays. Most of the names are self-explanatory:
array_copy
array_element_exists
array_get_dimensions
array_insert_slice
array_remove_slice
array_sort
array_complex_sort
(for multidimensional arrays)Over time, using modern languages like C# or JavaScript, I've become accustomed to having a more fully-featured Array datatype, so I set out to create something to make my life a little easier in VGL. I implemented a 1-dimensional LIST
class, using the JavaScript Array object as a model for which actions I implemented.
Here's an summary of the LIST
API:
JOIN LIBRARY lib_list
DECLARE list
lib_list_define_list_class()
CREATE OBJECT LIST_CLASS, list
As with many VGL class-oriented libraries, a routine to define the class needs to be called before the class itself is available for instantiation.
{ list contents }
list.append(1) { [1] }
list.append(2) { [1 2] }
list.push(3) { [1 2 3] }
list.unshift(4) { [4 1 2 3] }
The append
and push
actions are identical, adding a new element to the end of the list. The unshift
action adds a new element to the beginning of the list.
{ list contains: [4 1 2 3] }
list.get(3) { action returns: 2 }
list.set(3, 5) { list contains: [4 1 5 3] }
{ list contents }
{ [4 1 5 3] }
list.remove(1) { [1 5 3] }
list.pop() { [1 5] pop() returns 3 }
list.shift() { [5] shift() returns 1 }
Corresponding to unshift
and push
, shift
and pop
remove and return the first and last elements of the list, respectively. The remove
action returns the list object rather than the element that was removed.
{ list contents }
{ [1 2 3 4] }
list.reverse() { [4 3 2 1] }
There is a reverse
action to reverse the order of the elements in the list, but no sort
action.
{ list contains: [a b c d e] }
list.slice(2, 4) { action returns: [b c] }
list.slice(-3, EMPTY) { action returns: [c d e] }
list.slice(EMPTY, -2) { action returns: [a b c] }
The slice
action is used to copy a range of elements into a new list without modfying the original list.
{ list contains: [a b c d e f g] }
list.splice(2, 4) { action returns: [b c d e] }
{ list now contains: [a b f g] }
The splice
action is used to return a range of elements from the original list, removing them from the original list. This differs from the JavaScript Array.splice
method in that it cannot be used to insert or replace existing elements.
{ list contains: [a b b c d a] }
list.distinct() { action returns: [a b c d] }
The distinct
action returns a new list with all of the distinct elements in the original list. Elements appear in the returned list in the order in which they first appear in the original list. The original list is not modified.
{ list contains: [a b c a d] }
list.inBounds(0) { action returns: FALSE }
list.inBounds(3) { action returns: TRUE }
list.inBounds(6) { action returns: FALSE }
The inBounds
action returns TRUE if the given index is contained within the list. Remember, list indexes are 1-based in order to be consistent with VGL array indexes.
{ list contains: [a b c a d] }
list.length { statement value: 5 }
There is also a length
property containing the number of elements in the list. It should not be modified from outside the class actions, but it can be read.
{ list contains: [a b c a d] }
list.indexOf(a) { action returns: 1 }
list.indexOf(c) { action returns: 3 }
list.indexOf(e) { action returns: 0 }
list.lastIndexOf(a) { action returns: 4 }
list.lastIndexOf(c) { action returns: 3 }
list.includes(a) { action returns: TRUE }
list.includes(e) { action returns: FALSE }
The indexOf
and lastIndexOf
return the index of the first or last (respectively) occurrence of a given element in the list. If the element is not found in the list, the action returns 0
. The includes
action returns TRUE
if the given element is included in the list—it is implemented by returning TRUE
if indexOf
is greater than 0
.
{ list contains: [1 2 3 4] }
list.push(5).unshift(6).remove(2).pop()
{ action returns: 5 }
{ list now contains [6 2 3 4] }
Many actions that result in the list being modified will return a reference to the modified list. This allows multiple actions to be chained together in the same statement.
{ list contains: [1 2 3] }
list.join(", ") { action returns: "1, 2, 3" }
list.join("|") { action returns: "1|2|3" }
list.join(" and ") { action returns: "1 and 2 and 3" }
list.toString() { action returns: "[1,2,3]" }
The join
action returns a string containing the list elements delimited by the specified string. The toString
action returns a human-readable string containing the contents of the list suitable for logging or debugging purposes.
I have created a GitHub repository for this library and released it under the MIT License, which makes it easy to use in your own projects. If you have any problems with the library or suggestions on how it could be extended or improved, please create an issue or submit a pull request so that they can be tracked effectively.
If you find this useful and end up using it in a project, I'd love to know about it.
The rally bike is sold!
You can read a little bit about the history of the bike over here. Right now, it's set up like I had it at the 2019 Sandblast Rally.
There are a bunch more photos here.
There are a bunch more photos here.
There are a bunch more photos here.
I've owned this bike since 2014. I raced it in the 2019 Sandblast Rally (bike prep details) at the beginning of March and it ran flawlessly. Before that, I mostly just used it to putt around on trails and do some light dual-sporting. Although it has served me reliably, it is really built to be a desert rally racer and isn't at home as a dual-sport or trail bike. I am more of a slow trail rider so I think it's time for the 505 to move on to a new home.
The bike has a license plate and is street-legal with a normal Kentucky street title in my name.
The rally bike has been sold; thanks to everyone who was interested.
Now that the rally is finished, I want to recap some of what worked and what I could do differently next time.
The rally racing clinic on the Thursday before the race was really beneficial. It was a good introduction to the terrain that we would be racing on, and a good general refresher on riding off-road. While I have a lot of off-road riding under my belt, I have not ridden a lot over the last year. Also, the clinic did a great job explaining the logistics of rally racing and how the timing system works. On race day I felt pretty confident that I knew what I was doing.
The bike performed flawlessly. Although I did damage a fuel line during the rally racing clinic on Thursday, I was able to easily repair it at lunch and continue without any incidents on race day. The bike didn't miss a beat despite my complete lack of mechanical sympathy. Bonus: no flat tires.
I rode with an endurance race mindset. With a total of nine special stages, I rode conservatively to make sure that I still had energy left for the later stages. I stayed hydrated and fed—no bonking here.
Relating to the previous point, I also prioritized not crashing. Obviously crashing hurts, but even if it's a soft landing, a lot of energy is expended in picking the bike up and getting going again. I can't afford that.
I had fun. I'm not a competitive rider. Sure, it was great fun to occasionally pass someone, but for me it was more of an opportunity to legally ride as fast as I want (or can) in a controlled environment. If I saw someone coming up behind me, they had already proven themselves to be much faster than me (by gaining at least thirty seconds on me) so I just moved over and waved them by—everyone's happier that way.
The bike could take advantage of taller gearing. I topped out on the straights at around 80 mph. While that sort of speed on loose sand feels really fast, I know the bike has the power to easily go faster with taller gears. I could run a significantly smaller rear sprocket and gain some top speed without sacrificing bottom-end performance (since I was launching in 2nd gear anyways).
I need to learn to shift around my weight more. I was too focused on holding on and not focused enough on my body position, so there is considerable opportunity to increase my level of control by being intentional about managing my body position.
Commit to the line. Loose, rutted sand is a mind game. The bike will go where you look if you get on the gas and go for it. I was getting pretty comfortable with the straight sections but turns in the churned-up sand were still intimidating. This is an area where I need more practice to improve.
Push harder. I know the point is to have fun, but the point is also to go fast. This is a controlled environment: the course is closed to traffic and the turns and hazards are marked. I rode to conserve energy, but I left a lot more in reserve than I needed to.
Reduce the tire pressure. I ran with about 18 psi, but could have gone down to 15 or even 12 psi for a little more traction. This would help the bike feel a little more planted and hook up better while accelerating out of the corners.
Get in shape. I'm happy with how I did but I know that my own physical fitness is one of the big things that holds me back. Working to lose 40-50 lbs and improving my cardiovascular capacity would be a huge boost to my performance.
Overall, my entry in the 2019 Sandblast Rally was a success! I finished the race, didn't hurt myself or the bike, and had a lot of fun. Plus, I made some new friends. If time and resources allow, I would like to race again here or elsewhere in the future.
I'm officially a 2019 Sandblast Rally finisher! As racer #134, I placed 10th out of 17 in my class (MM), and 22nd out of 41 bikes overall. Here are the official results from NASA Rally Sport. I wasn't wicked fast, but I didn't hurt myself or the bike, and I had a lot of fun in the process.
Now it's time for a nap.
I'm heading down to South Carolina today to attend the rally racing clinic and then the Sandblast Rally itself. I am not bringing a computer with me, so I probably will not add any more posts until I get back.
In the meantime, follow my Facebook page and Instagram feed to stay up to date on how things are going.
Wish me luck!
I've been out of town for about a month, so let's pick up about where we left off in January. Now it's only four days until the 2019 Sandblast Rally! I have a long list of things to take care of before I leave tomorrow; I think it's going to be a late night.
A few weeks ago, I signed up for Bill Conger's rally racing clinic held on Thursday before the race. This will give me some productive seat time before shakedown on Friday and the race on Saturday—my main goal is to get reacclimated to the sand and reduce the chance of getting hurt. Plus, it looks like fun!
My last work on the bike was the oil change and valve check, but I still have a leaky countershaft seal to replace. I have the parts here, and it's on my to-do list for tonight.
A few other bits need some attention tonight as well:
Finally, I need to mount the new tires. It's probably my least favorite maintenance item to deal with, but it needs to be taken care of tonight.
I'm not talking about packing my suitcase for the trip, but how I'm going to pack the required equipment onto myself and/or the bike. As I've discussed in a previous post, I need to carry a first aid kit and a few documents, and some water and/or sports drink would be good too.
I've been experimenting with carrying my Camelbak backpack while wearing my Leatt body protector, but it's not ideal since the neck brace interferes with where the backpack shoulder straps would normally go. I'll use the rollie bags and carry a water bladder in there instead of on my back. I think that there will be plenty of time during transit or waiting for my check-in times to snag a drink—I'm not going to be drinking on the stage regardless.
The rules mandate a minimum of 10 square inches of reflective material on the front of my person or helmet, and another 10 square inches on the back (not obscured by any backpack). I have an inexpensive reflective safety vest that fits over the Leatt to satisfy this requirement.
The forecast for the rally racing clinic on Thursday is calling for some rain, but with temperatures expected in the 60s, it will still be good for riding. Hopefully the sand will drain well and keep the mud to a minimum.
The forecast for Friday and Saturday is looking great, with no rain and expected temperatures generally in the 60s. Good cloud cover is predicted for both days, minimizing those annoying sun-in-the-eyes sections.
Part of the periodic maintenance on the KTM is to make sure that there's the appropriate clearance between the cam and the followers when the valves are fully closed. Too much clearance means that the valves are not opening the whole way and causes extra noise and wear from the drive train. Too little clearance means that the valves are not seating fully and can make the bike hard to start or cause burned valves because much of the heat is transferred to the head through the seat when the valve is closed.
As an engine wears over time, the valve clearance generally decreases as the hardened steel valve seats are very slowly pushed further into the aluminum head by the hammering of the valve. In this bike, there are small steel shims that fit in the cam follower to allow some adjustment. If the clearance is less than the appropriate amount, the shim is replaced with a slightly thinner (like by 0.0015") shim so that the cam-to-follower clearance is corrected.
The specifications for this motor call for 0.003-0.005" clearance for the intake valves and 0.005-0.007" clearance for the exhaust valves—this is measured when the engine is cold. The clearance for the exhaust valves is usually specified to be more than the intake valves because they will get hotter and expand more than the intake valves.
Although space is a bit tight, checking the valve clearance in an RF4 motor is really straightforward.
If you have to swap shims to adjust the clearance, you need to remove the cams and it gets a little bit more finicky. Fortunately, all of the valves on the bike were in spec, so I just buttoned it back up. I'm a little surprised that everything was in spec—I think it means I just need to ride the bike harder!
It was nice to do some maintenance that didn't actually require any expense other than my time.
It's long overdue, but I was busy with other things recently. Last night I finally finished the vinyl on the fairing! I zipped home from work at lunch today to pull the bike out into the daylight for a few photos.
I love how it all came out. There were a few points in time where I was a little skeptical about how it was going, but in the end the effort was totally worth it. I'm neither an artist nor a professional wrapper, but I'm thrilled with the result.
Of course, now there's a huge pile of scraps still on my kitchen table that I should probably clean up soon.
This evening I changed the oil on the KTM. Unlike some other KTM models (cough cough RFS cough cough), the RF4 motor in the 505 XC-F is pretty easy to service. I used 1.25L of Motorex 10W-50 synthetic oil and a K&N filter, and it was all finished in about 25 minutes. No fuss, no mess!
I also wanted to change the countershaft (front) sprocket from a 13-tooth sprocket to a 14-tooth sprocket. This will give me a little more speed for the same RPMs. I previously was using the bike for more trail riding. I have a 46-tooth sprocket on the rear wheel and I'll leave that as it is.
Changing the countershaft sprocket on a KTM dirt bike is pretty easy. I loosened the rear axle and slid it forward, giving me plenty of slack in the chain. I also loosened the countershaft sprocket guard and moved it out of the way. After removing the countershaft sprocket bolt and dome washer, it was easy to slip the 13T sprocket off.
At this point I noticed there was a bit more oil there than there should be—the countershaft seal was leaking a little. This is a normal wear item, so it's fairly easy to replace. Unfortunately, I had to order the parts (about $25 in total), so I finished the sprocket swap without addressing the leak. The parts should be here at the end of the week and then I can replace the seal.
On the plus side, the chain was already long enough to handle an additional tooth on the countershaft sprocket without having to add a another link.
In order to get a NASA rally license, you have to see a doctor and fill out a medical evaluation form. The doctor will check your usual vitals, test your peripheral vision and neurological function, and make a determination as to whether or not you are fit to receive a rally license. Since this is directly related to getting the rally license, I included it on the expenses list in the Entry category.
Keep in mind that health insurance doesn't cover things like this. If you are due for an annual physical, your doctor may be able to complete this as part of the physical since basically everything in here should be something they're already doing.
The main goal of the evaluation is to make sure that you don't have any underlying condition that might preclude competitive motorsports. If you have a history of seizures or blackouts or a trick heart, then you probably shouldn't be racing and your doctor will tell you that. So don't stress out about it if you're out of shape (like me) but otherwise generally healthy—you're in good company.
I already had my annual physical recently, so I went down to the local walk-in clinic for this since I was going to be paying out of pocket. The evaluation itself took about 20 minutes (after about a 30 minute wait) since I had already filled out my medical history on the form before meeting the doctor.
Once you have the form completed, you can scan, fax, or mail it to NASA Rally Sport. Depending on your age, you may not have to deal with it again for a few years.
I've decided to make some custom graphics for the bike. The previous owner of the fairing did a cool Star Wars-themed paint job, but I want to go a different direction. The previous owner of the bike was kind enough to send me the outlines for the custom graphics that he previously had on the bike, so I have that to start with.
Yesterday, I printed the outlines; it came to a total of 42 pages that I need to cut up and tape together into fourteen different stencils. I started by labeling each of the stencils on a "map" and then went through each of the 42 pages and labeled each piece of each stencil so I didn't get anything mixed up. It took about a half hour to identify and label all of the pieces.
Each of part of each stencil needs to be carefully cut out of the page so that the stencil pieces can be taped together into a complete stencil. I pulled out my trusty paper cutter, razor blade, and scissors, and got to work. This took forever. I don't ever want to see a pair of scissors again.
It got pretty late so I called it a night with the stencils cut and sitting on my kitchen table.
This evening I got back to work. I cut a piece of vinyl sized to fit a stencil and taped it flat to my workbench. Then I took the appropriate stencil and taped it down onto the flattened piece of vinyl. Finally, I carefully cut out the vinyl in the shape of the stencil.
The vinyl is then carefully applied to the body of the KTM. I pulled the fairing and plastic off of the bike so it's a bit easier to work with.
It's getting late again, so I'll have to finish it later. Here's a sneak preview.
I'm excited about how it's all going to come out; I can't wait to show you all the finished product!
The bike is full of mud from the last time I took it out on the Redbird Crest Trail down in the Daniel Boone National Forest.
Step one to getting it cleaned up is to take it to a self-serve car wash to hit it with some soap and water. However, it's currently winter, and water tends to become rather solid at temperatures below freezing, making cleaning the bike a bit of a challenge. Fortunately, the temperatures today were forecast to rise up to a balmy 35°F, slightly above freezing.
I bundled up and rode the bike to work today with the hopes that it would be above freezing around lunchtime and I could ride over to the car wash to get it cleaned up without the risk of everything freezing solid before it dried. (Our Kentucky winters are relatively mild; the local self-serve car wash uses warmed water and appears to stay open year-round.)
By 12:30 p.m., it was up to 30°F and the sun was out—that was close enough for me.
After a quick spraydown, I rode bike back home and parked it in the garage. The garage isn't directly heated but it's unusual for it to get below freezing in there.
Now I wouldn't quite call the bike "clean", but at least it's not muddy anymore. This weekend I'll pull off the bodywork and start to make sure that everything is race-ready under the skin.
The current tires on the KTM are fine for casual trail riding around here, but not really race-ready. There's a Bridgestone M403 on the front, a motocross tire which was discontinued a few years ago, and a Dunlop MX81 on the rear, which is a little newer but is showing some wear.
While they'd surely get me through the race, I like to stack the deck in my favor as much as is allowed and opted for some new shoes. Since most of the special sections of Sandblast are run on very sandy roads—as one should expect from the name—I went with a pair of Shinko 546 soft-intermediate terrain tires.
Being "soft-intermediate terrain" tires means that they're made from a harder rubber and with a wider-spaced tread pattern in order to provide good traction in softer terrain. The trade-off is reduced traction on pavement, which is acceptable given that the real racing primarily occurs on the soft stuff. I don't have any prior experience with this specific tire model, but they are inexpensive and seem to be pretty well-liked. Realistically, as long as there's air on the inside and some knobs on the outside, the tire isn't going to make or break the race for a novice competitor such as myself.
Like most real off-road tires, these tires are not DOT-approved for highway use. In some states, DOT-approved tires are technically required, though this seems to be rarely enforced. South Carolina, where Sandblast is held, does not require (as far as I can tell) that tires be DOT-approved for highway use in order to be legally used on public roads. The race regulations also do not specify that DOT-approved tires are required.
The rear tire is a 110/100-18 size, which is the widest variant that will fit my bike. Shinko do make a 120/100-19 but that's for the 19-inch rear wheels normally found on motocross bikes. Enduro bikes, like my bike, typically have an 18-inch rear wheel diameter. Normally I tend to run a wider tire, but I think the difference will be negligible, especially on the softer terrain.
The front tire is a 90/100-21 size, which is sometimes referred to as a "fatty" front tire. It's a size bigger than the usual 80/100-21 or 90/90-21 front tires, which raises the front end a hair, adds a little more front-end cushioning, and slightly changes the steering geometry. The steering geometry change tends to add a little bit of directional stability, which is always welcome in sand. (For what it's worth, I'm not running a hydraulic steering stabilizer on the bike.)
While normally I run with the front and rear tires each stuffed with a mousse instead of a tube so I don't have to worry about flats, the use of mousses is not allowed at Sandblast. Sad panda. I picked up a set of heavy-duty Kenda rubber tubes to keep the air where it's needed. I'll have spares at the truck so I can change if needed, but I'm not planning on any trailside tube patches during the race.
In the meantime, I'll keep running the old tires. I'll spoon the new ones on before I head down to Sandblast, but there's no reason to change them quite yet.
Obviously it's generally a good idea to carry a first aid kit when you're out riding. In this case, a first aid kit is specifically required by the rules. It states that at a minimum, the first aid kit should consist of:
It's basically impossible to find an off-the-shelf first aid kit with just those items, so I purchased an inexpensive but relatively comprehensive home first aid kit. I pulled the required items out of the kit, along with a few more things:
I've taken everything and packed it into a gallon-sized ziplock bag. Note that the scissors are quite sharp however they have blunt ends so I don't think they'll inadvertently puncture the bag or anything in it. This bag will go, along with a folding FMVSS #125 warning triangle and the required OK/help placard, into a small bag mounted on the back of the bike or into my hydration pack. I haven't figured out storage yet, but according to the rules, it can be either mounted to the bike or mounted to me—either is acceptable as long as it is easily accessible.
Generally I like to minimize what I'm carrying on my back, but I need to assess how things could mount to or fit on the bike—it might just make more sense to throw everything into my hydration pack. I'll write a post later on how I'm carrying the required equipment once I sort everything out.
It's important to be prepared, but here's hoping that I won't have to use anything in the first aid kit on myself or anyone else!
After talking to one of my friends who is a medical professional, I upgraded the scissors to a proper pair of trauma shears. They're inexpensive and far more versatile than the super basic scissors that were included with the home first aid kit. I also added an Israeli bandadge and SAM Splint. The whole kit is a little more bulky now, but still not much heavier.
The 2019 Sandblast Rally has an entry fee that varies based on what type of vehicle you are competing in and when you register. Since I am a first-time competitor, I was eligible for early entry, which began in late December. For motorcycles, the early entry fee was $290. Notably, this is an increase of $30 from last year.
But that's not all. Since Sandblast is part of the NASA Rally Sport (NRS) Atlantic Rally Cup, I need to become a member of NRS—not to be confused with the spacegoing NASA---and also obtain a NASA rally license. In order to become a member of NRS, I just had to part with $45 for a 1-year membership; easy-peasy.
To get a NASA rally license, there's an extra step. In addition to the $65 rally license fee, I also had to get the medical form completed by my doctor and sent over to NRS. This isn't anything crazy, just a quick once-over and sign-off that you're shipshape and safe to race. No one wants you to have a seizure or heart attack while you're on the course.
So that's $400 in fees just to get in the door.
Motorcycle racing isn't cheap.
In the interest of full transparency, here's a tally of my costs involved. I've grouped the expenses into several categories:
Keep in mind, as I have been riding for years, I already have a lot of motorcycle gear. Don't consider this an exhaustive list of what's needed to participate in an event like this—notably, I also did not include any food costs. Most items in the list are linked to a relevant blog post.
Amount | Category | Description |
---|---|---|
290.00 | Entry | Sandblast Rally 2019 motorcycle early entry fee |
45.00 | Entry | NASA Rally Sport 365-day membership fee |
65.00 | Entry | NASA rally license fee |
70.00 | Entry | Medical evaluation |
220.00 | Racer | Leatt Fusion 3.0 body protector (used) |
24.95 | Racer | First aid kit |
130.00 | Racer | Bill Conger's Rally Racing Clinic |
200.00 | Vehicle | Rally Moto Kit fiberglass fairing (used) |
62.61 | Vehicle | WPS Featherweight lithium battery |
123.11 | Vehicle | Shinko 546 front and rear tires |
41.71 | Vehicle | Kenda font and rear heavy duty tubes |
10.00 | Vehicle | IRC front and rear rim strips |
26.95 | Vehicle | Enduro Engineering roll chart holder |
52.58 | Vehicle | Motorex 10W50 synthetic oil and K&N filter |
14.13 | Vehicle | replacement Acerbis gas cap |
16.07 | Vehicle | replacement seat bolt kit |
15.06 | Vehicle | countershaft seal kit |
9.29 | Vehicle | countershaft sprocket bolt and dome washer |
78.77 | Cosmetic | vinyl wrap materials |
281.93 | Travel | 3 nights lodging near the rally |
208.91 | Travel | fuel for the truck |
Total cost: not cheap
Although the entry fee for Sandblast is relatively low, that's only a small part of the expense of participating in an event like this. Many thanks to my coworker and friend Andrew for his support in getting me to the 2019 Sandblast Rally.
Racers participating in the Sandblast Rally are required to have chest and back protection—this is common gear for motocross and enduro racers. However, as just a casual rider, I typically wear an armored jacket like one normally does for street riding. So I needed to find a hard-shell chest and back protector to wear for the race.
Neck protection is also a thing. Leatt are the best-known name in neck protection in the motorcycling world. The Leatt neck braces are probably better described as neck restraints, with a similar function to a HANS device in the auto racing world.
In conjunction with a full-face helmet, the Leatt neck brace helps limit extreme movement of the neck by dispersing forces to the shoulders and chest. While this doesn't prevent all neck injuries, it reduces the risk of severe neck injury during a crash. Leatt have been making a variety of CE-approved neck brace models for the past decade.
Neck restraints are not required by NASA RallySport (the sanctioning body behind Sandblast) for competitors in either cars or bikes, however I think it's a wise choice (and there's data to back that up). I was able to find a lightly-used Leatt Fusion 3.0---a chest and back protector with an integral Leatt neck brace—on the secondhand market for essentially half price, so I snapped it up.
It arrived today, and it fits comfortably. This one has shoulder pads, which is a nice bonus. And yes, that's a bathroom mirror selfie—I'm classy like that.
After the incident bringing the bike home from the storage unit, it was clear that a new battery was needed. I ended up ordering a WPS Featherweight lithium battery while I was waiting around in the parking lot for roadside assistance to come and give me a jump. It arrived today! Hurray!
The current battery in the KTM is an old lithium battery by Sycl (no longer in business) that has been in the bike for at least five years. It might be salvageable but I feel like it's worth the relatively low cost to replace it, especially since
I really want the magic button to work reliably.
I chose the WPS battery because it was an affordable, lightweight, and of course won't spill when I dump the bike if the bike needs a nap. It has an integrated battery capacity indicator on top that gives you a low/medium/high indication by way of some LEDs at the press of a button. I know that it's more a gimmick than anything else, but it is cool.
The new battery slipped right in and the cables attached to the terminals without any interference; there's good power to the lights and fan now. It's a bit late tonight to fire it up—the neighbors have little kids that I don't want to wake up—but I'll take it out for a spin (probably to the car wash) tomorrow.
The KTM is equipped with a first generation Rally Moto Kit by my friends at Motominded. Unlike the second generation (released just last December), which uses a factory KTM 450RR windscreen, the first generation used a custom fiberglass fairing.
Here you can see what it looked like on that first day I brought it home:
A few months later, I was riding the bike with a friend out in North Carolina when I had a slight mishap. While both the bike and I were largely unscathed, the fairing suffered a mortal blow and ripped into two pieces.
Only a handful of the first-generation Rally Moto Kits were made, and spare fiberglass was nigh impossible to find. Even the original molds were no longer serviceable, so no more could be made.
This is repairable if you are good with fiberglass. However, I'm not, so I've been riding the bike since 2014 with no fairing at all. The fairing did provide some useful wind protection, but the navigation tower still does a great job of holding my GPS and gives a place to mount the headlights.
Recently—with Sandblast in mind—I inquired with Motominded if they thought that the KTM 450RR windscreen used in the second-generation could be somehow mounted to the first-generation Rally Moto Kit. The short answer was, unfortunately, "not easily", but they were able to refer me to a mutual friend who had a second set of fairings from the first-generation model.
He was willing to sell me his second set of fairings at a very reasonable price, and they arrived just recently. A talented previous owner had painted them with a Star Wars theme, which I think is awesome, and the paint job is holding up pretty well.
Since I brought the bike home to my garage last night, I couldn't resist a quick test-fit of the fairing. I was expecting to need some tweaks since these are older bikes and might be a little tweaked here and there themselves, but it fit easily without any need to "use the force" to make things line up.
I'll need to find a few pieces of hardware finish mounting it properly, but it's starting to look like a real rally bike again!
<script type="text/javascript">Now that the KTM is street legal, it's time to get it from the storage unit where it lives over to my tiny garage. This means I need to ride the Crosstourer over to the storage unit, jump-start the KTM (since the battery is kaput), tuck the Crosstourer into the storage unit, and then ride the KTM back to my garage. No problem, right?
Well, I made it part of the way home before I turned off the choke and promptly killed the motor. Oops. The bike doesn't warm up very quickly in the near-freezing winter weather. There was not enough juice in the battery to restart the bike, so I called roadside assistance for a jump. Prepared for a long delay, I ordered a new battery while I sat there waiting for help to arrive.
Fortunately, the tow truck showed up after only about twenty minutes. With a boost from a big battery, the bike started easily and I made it the rest of the way home without any issues.
The new battery should arrive next week.
I bought my first real motorcycle back in 2005. It was a 1981 Suzuki GS550 that mostly ran and sometimes ran well enough to get me to class and back. I was hooked. The GS550 was quickly followed by a 1983 Honda Nighthawk 650 that was smooth and reliable and took me to and from work, school, and all over East Texas—rain or shine.
A long list of bikes have since graced a spot in my garage. In 2011, I was introduced to the art of off-road riding by a Suzuki DR650, and I was quickly sucked into the world of dual-sport and adventure riding. I've been riding a mix of on-road and off-road ever since. Currently, I own a 2009 KTM 505 XC-F and a 2016 Honda VFR1200X.
I attended the 2018 Sandblast Rally as a course volunteer; after seeing the action up close, I knew I had to try it for myself. I have accumulated thousands of miles of experience riding off-road on big adventure bikes (like my Moto Guzzi Stelvio and Africa Twin), dirt bikes, and even a Ural sidecar rig.
On March 2, 2019, I left the starting line at 8:46:00 a.m. and launched into the 2019 Sandblast Rally as a competitor. A little less than 10 hours later and a lot muddier, I rolled to the finish line to hand in my timecard as a finisher. What a blast!
I rode a 2009 KTM 505 XC-F for the 2019 Sandblast Rally. This bike was modified with a frame-mounted nav tower and fairing that was originally made by MotoMinded for Neduro's 2012 Dakar entry. Before I purchased the bike, it had also been raced by Ned in the 2013 Touareg Rally and the 2013 Baja Rally, so if I didn't make it to the finish, it wasn't because of the bike!
Race vehicles for the Sandblast Rally need to be registered and insured street-legal vehicles. I hadn't yet gone through that process for the KTM, and I had some concern that I might need to jump through some extra hoops since KTM originally only sold the 2009 505 XC-F as a off-road vehicle.
The bike was previously converted from an off-road vehicle to a street vehicle in Colorado, which means that it went through an inspection process and received a title and license plates like any other street-legal bike or car. However, I'm not in Colorado anymore; any vehicle coming into Kentucky from outside the state requires an inspection by the county sheriff's office.
The sheriff's inspector verifies that the VIN number on the frame matches the VIN number on the documentation, and they check for some basic mechanical requirements--here's a non-exhaustive list based on my observation of the process:
Note that neither DOT-approved tires nor turn signals are required on motorcycles to be registered in the state of Kentucky.
I first took the bike to the inspector at my nearest county clerk's office. This is where I've done the paperwork for my other vehicles and it's generally been a painless process. Unfortunately, the inspector there was not really well-informed on the law or motorcycles in general and refused to inspect the bike since it was not equipped with turn signals.
The inspector was using an inspection checklist that was clearly written for cars, so I pointed out to him that no motorcycles are equipped with doors, which were also on his checklist. It went predictably downhill from there and I left a bit frustrated and without the inspection that I needed in order to register the bike.
I called the sheriff's office to confirm with them that turn signals were not required. They suggested trying another inspector so I traveled across the county to the county clerk's other office location. There the inspector was much more informed and gave me a passing inspection without any issues despite copious amounts of mud caked everywhere and a mostly-dead battery.
I was able to easily add the KTM to my existing motorcycle policy online without any roadblocks. My annual insurance premium actually decreased by about $50 with the addition of the bike.
Armed with a passing inspection and proof of insurance, getting the bike registered in Kentucky was as simple as handing the clerk the Colorado title, the inspector's report, and freshly-faxed proof of insurance. After handing over some hard-earned cash to cover the taxes and fees, I was able to walk out with some fresh plates.
Success!
Continuing to build on the first and second installments, let's try to reduce the wrapping used by expanding to fill the page width.
We'll continue to use the same data as before, but with the Moisture Content and Volatile Matter columns removed to give us room to expand. Thus we start with this:
Gross
Caloric
Fixed Value at
Carbon by Constant
Sample Difference Volume Ash
Name Date wt. % J/g wt. %
------- ----------- ---------- -------- -------
X24-03 01-Nov-2018 15.62 19985 0.25
X24-02 31-Oct-2018 16.01 20004 0.23
X24-01 30-Oct-2018 15.89 19996 0.24
The output above only uses 48 columns of text, so you can see there's lots of room to grow.
We need to define how many columns of text can fit on a page. The code blocks that I use on this site comfortably fit about 80 columns—at least in my browser—so we'll use that. We'll set the page width next to where we set the minimum column width.
// page width and minimum column width
const minimumColumnWidth = 7;
const pageWidth = 80;
Our approach to filling the space will be by increasing the width of the most-wrapped header columns in an effort to reduce the wrapping. We define most-wrapped as the column header that has the greatest height.
To do this, let's first reorganize our code a bit to make it more modular. First, we'll make a word-wrap function based on the wrapping code we've used previously. Note that it returns an array of lines rather than a single string with newlines.
// wrap text to a specific length
function wrapText(str, len) {
// split the string into words and use Array.reduce() to condense it into
// lines that are all less than or equal to the target length
return str.split(/\s+/).reduce((accumulator, current) => {
// is this the first word?
if (accumulator.length == 0) {
// start a new line with the first word
return [current];
} else {
// get the last line
const lastLine = accumulator.pop();
// add the next word to the last line
const testLine = lastLine + ' ' + current;
// is this line less than or equal to the target length?
if (testLine.length <= len) {
// add the line to the list
return accumulator.concat(testLine);
} else {
// otherwise add the unmodified line to the list and start a
// new line with the next word
return accumulator.concat(lastLine, current);
}
}
}, []);
}
Next, let's take our code to extract the units from the first data row of the grid and strip them out of all of the values and move it all into its own function which modifies the grid and returns the units. This is updated a little bit from the previous installment in order to modify the grid in place.
// extract the units from a string
function extractUnits(grid) {
// check each cell in the first data row of the grid
const units = grid[1].map(str => {
// compare with the regular expression
const matches = str.match(/\d+(\.\d+)?\s+(.+)/);
// was there a match?
if (matches !== null) {
// return the units
return matches[2];
} else {
// otherwise return false
return false;
}
});
// remove the units from the grid
grid.forEach((row, i) => {
// is this a data row?
if (i > 0) {
// if we have a unit, remove it from the cell
grid[i] = row.map((x, j) => units[j] ? x.split(/\s/)[0] : x);
}
});
// return the units; the grid is already modified
return units;
}
Finally, let's take the code which renders the grid into text and put that into its own function. As an input, we'll give it the main grid data, the units, and the desired column widths.
// render the grid into text
function gridToLines(grid, units, columnWidths) {
// wrap the column headers based on the calculated widths
const headers = columnWidths.map((w, i) =>
wrapText(grid[0][i], w).concat(units[i] ? units[i] : []));
// how many header lines do we need?
const headerHeight = Math.max(...headers.map(x => x.length));
// pad our headers with blank lines so the content is bottom-aligned
headers.forEach(h => {
h.unshift(...new Array(headerHeight - h.length).fill(''));
});
// format as lines
return new Array(headerHeight)
.fill('').map((h, i) =>
columnWidths.map((w, j) => headers[j][i].padEnd(w)).join(' '))
.concat(columnWidths.map(w => ''.padEnd(w, '-')).join(' '))
.concat(...grid.slice(1).map(row =>
columnWidths.map((w, j) => row[j].padEnd(w)).join(' ')));
}
Now that we have our existing code a little better organized, let's move into the new stuff. We want to incrementally make the tallest (defined by header height) column wider until it is shorter and we still fit on the page. Let's start by writing a function which, given a string and a target height, will tell us the smallest wrapping width to acheive the target height.
// find the minimum width to wrap text to a target height
function calcWidth(str, targetHeight = 0) {
// start with the minimum width possible without breaking words
let width = Math.max(...str.split(/\s+/).map(w => w.length));
// calculation for the height
const heightCalc = (str, width) => wrapText(str, width).length;
// increase the width until we reach the target height
while (targetHeight > 0 && heightCalc(str, width) > targetHeight) {
width++;
}
// return the width used to hit the target height
return width;
}
Note that in targetHeightWidth()
we made targetHeight
an optional parameter with a default value of zero. We'll reuse this function later to calculate the minimum possible width of a column.
Now we get to the meat—how do we expand things to fill the page width? The basic algorithm is this:
Here's the code integrated in a function to calculate the column widths.
// find the optimal column widths
function calcWidths(grid, units, minimumColumnWidth, pageWidth) {
// calculate the minimum column widths
let columnWidths = new Array(grid[0].length)
.fill(minimumColumnWidth)
.map((width, i) => Math.max(...grid.map((row, j) =>
j === 0 ? calcWidth(row[i]) : row[i].length).concat(width)));
// iterate until we fill the page
let newWidths = columnWidths.slice(0);
do {
// use the new widths
columnWidths = newWidths;
// find the tallest column
let tallest = columnWidths
.map((w, i) => ({ i, h: wrapText(grid[0][i], w).length }))
.sort((a, b) => a.i - b.i)
.sort((a, b) => b.h - a.h)[0];
// if our tallest column has a height of 1, bail on the loop
if (tallest.h <= 1) break;
// make a copy of the column widths
newWidths = columnWidths.slice(0);
// update the width of the tallest column
newWidths[tallest.i] = calcWidth(grid[0][tallest.i], tallest.h - 1);
// repeat if we're still under the target width
} while (newWidths.reduce((a, c) => a + c + 1, -1) <= pageWidth);
// return the column widths
return columnWidths;
}
It's not an optimal algorithm, but it gets us close enough to be functional. Perhaps we'll improve on it in a later iteration.
We've refactored the code into a bunch of functions, so let's put it all together. Here's the main code that calls the functions above:
// page width and minimum column width
const minimumColumnWidth = 7;
const pageWidth = 80;
// populate the grid from our input data
const grid = [Object.keys(input.data[0])];
input.data.forEach(row => {
grid.push(grid[0].map(key => row[key]));
});
// extract the units from the grid
const units = extractUnits(grid);
// calculate the column widths
const columnWidths = calcWidths(grid, units, minimumColumnWidth, pageWidth);
// print out the grid
console.log(gridToLines(grid, units, columnWidths).join('\n'));
With a specified page width of 80 columns, this is the output that we get:
Gross Caloric Value
Fixed Carbon by Difference at Constant Volume Ash
Sample Name Date wt. % J/g wt. %
----------- ----------- -------------------------- ------------------- -------
X24-03 01-Nov-2018 15.62 19985 0.25
X24-02 31-Oct-2018 16.01 20004 0.23
X24-01 30-Oct-2018 15.89 19996 0.24
You can download the complete code here.
In the next iteration, we'll handle wrapping of the entire table if the columns are too wide to fit in the width of the page.
Building on the first installment, let's improve the formatting by pulling the units out of the rows and putting them in the header instead. We'll use the same data as before.
Using a regular expression, we can easily determine if a cell contains a numeric value with a unit and then extract both parts. Let's create a function that takes the cell contents as an input and returns the unit (or false
if no units are found).
// extract the units from a string
function extractUnits(str) {
// compare with the regular expression
const matches = str.match(/\d+(\.\d+)?\s+(.+)/);
// was there a match?
if (matches !== null) {
// return the units
return matches[2];
} else {
// otherwise return false
return false;
}
}
Now that we have a function that will parse the string and pull out the units if there are any, let's create an array that contains the units for each column (based on the first row). We are making the assumption that the units will be the same in every cell in a given column, and that the first data row exists with no blank values.
// make a list of units based on the first row of data
const units = grid[1].map(value => extractUnits(value));
Now that we've determined what the units are, we need to remove the units from the grid.
// remove the units from the grid
grid = grid.map((row, i) => {
// is this the header row?
if (i == 0) {
// don't change anything
return row;
} else {
// if we have a unit, remove it from the cell
return row.map((x, j) => units[j] ? x.split(/\s/)[0] : x);
}
});
With the units moving to the header, we need to make sure the column widths are all wide enough. Let's update the width calculation to make sure that the units are included when we're looking at the header row words. We consider the whole unit string as a "word" in this case as we do not want to break in the middle of the unit string.
// calculate the column widths
let columnWidths = new Array(grid[0].length)
.fill(minimumColumnWidth)
.map((width, i) => Math.max(...grid.map((row, j) => {
// is this the first row?
if (j === 0) {
// find the width of the longest word (including the units)
return Math.max(...row[i].split(/\b/) // words
.concat(units[i] ? units[i] : []) // units
.map(w => w.length)); // lengths
} else {
// find the width of the whole contents
return row[i].length;
}
}).concat(width)));
Now that we've extracted the units and taken them into consideration when sizing the columns, let's update the code to print the units as part of the column headers. The units will come out on the bottom line of the header.
// wrap the column headers based on the calculated widths
let headers = columnWidths.map((w, i) => {
return grid[0][i].split(/\s+/).reduce((accumulator, current) => {
if (accumulator.length == 0) {
return [current];
} else {
const lastLine = accumulator.pop();
const testLine = lastLine + ' ' + current;
if (testLine.length <= w) {
return accumulator.concat(testLine);
} else {
return accumulator.concat(lastLine, current);
}
}
}, []).concat(units[i] ? units[i] : []); // add units
});
With our changes, the output now looks like this:
Gross
Caloric
Fixed Value at
Moisture Volatile Carbon by Constant
Sample Content Matter Difference Volume Ash
Name Date wt. % wt. % wt. % J/g wt. %
------- ----------- -------- -------- ---------- -------- -------
X24-03 01-Nov-2018 4.85 79.29 15.62 19985 0.25
X24-02 31-Oct-2018 4.52 80.91 16.01 20004 0.23
X24-01 30-Oct-2018 4.68 80.03 15.89 19996 0.24
You can download the complete code here.
In the next iteration, we'll improve the layout by expanding to fill the page width.
Here's the scenario: we have a system that outputs plain-text reports with data formatted into a table. Our raw data comes in JSON format; we'll use this as the input to our program:
{
"data": [
{
"Sample Name": "X24-03",
"Date": "01-Nov-2018",
"Moisture Content": "4.85 wt. %",
"Volatile Matter": "79.29 wt. %",
"Fixed Carbon by Difference": "15.62 wt. %",
"Gross Caloric Value at Constant Volume": "19985 J/g",
"Ash": "0.25 wt. %"
},
{
"Sample Name": "X24-02",
"Date": "31-Oct-2018",
"Moisture Content": "4.52 wt. %",
"Volatile Matter": "80.91 wt. %",
"Fixed Carbon by Difference": "16.01 wt. %",
"Gross Caloric Value at Constant Volume": "20004 J/g",
"Ash": "0.23 wt. %"
},
{
"Sample Name": "X24-01",
"Date": "30-Oct-2018",
"Moisture Content": "4.68 wt. %",
"Volatile Matter": "80.03 wt. %",
"Fixed Carbon by Difference": "15.89 wt. %",
"Gross Caloric Value at Constant Volume": "19996 J/g",
"Ash": "0.24 wt. %"
}
]
}
Using the built-in JSON.parse()
function, we take the above JSON and interpret it as a JavaScript object called input
. Next, we need to transform it into a simple 2-dimensional array of strings that we'll refer to as our grid. The first row of the grid is made up of the column names, and each row after that is data.
// initialize our grid with the first row as the keys of the input data objects
let grid = [Object.keys(input.data[0])];
// load the rest of the rows into the grid
input.data.forEach(row => {
grid.push(grid[0].map(key => row[key]));
});
Let's continue by calculating the minimum column width for each column in the grid. We'll say that we don't want any columns narrower than 7 characters wide. We also don't want to wrap any of the actual data values, so we'll break down the column headers (in grid[0]
) into words but not the rest of the rows.
// minimum column width
const minimumColumnWidth = 7
// calculate the column widths
let columnWidths = new Array(grid[0].length)
.fill(minimumColumnWidth)
.map((width, i) => Math.max(...grid.map((row, j) => {
// is this the first row?
if (j === 0) {
// find the width of the longest word
return Math.max(...row[i].split(/\b/).map(w => w.length));
} else {
// find the width of the whole contents
return row[i].length;
}
}).concat(width)));
Great, now that we have the width of each column, we can continue with outputting the column header lines. Because of the wrapping, some column headers will take more lines than others, so we'll take care to pad the header lines such that the column headers are aligned to the bottom.
// wrap the column headers based on the calculated widths
let headers = columnWidths.map((w, i) => {
return grid[0][i].split(/\s+/).reduce((accumulator, current) => {
if (accumulator.length == 0) {
return [current];
} else {
const lastLine = accumulator.pop();
const testLine = lastLine + ' ' + current;
if (testLine.length <= w) {
return accumulator.concat(testLine);
} else {
return accumulator.concat(lastLine, current);
}
}
}, []);
});
// how many header lines do we need?
const headerHeight = Math.max(...headers.map(x => x.length));
// pad our headers with blank lines so the content is bottom-aligned
headers = headers.map(h =>
new Array(headerHeight - h.length).fill('').concat(...h));
// create the headers
let lines = new Array(headerHeight).fill('').map((h, i) =>
columnWidths.map((w, j) => headers[j][i].padEnd(w)).join(' '));
// create the separator lines
lines.push(columnWidths.map(w => ''.padEnd(w, '-')).join(' '));
Printing the data rows is a bit simpler as we do not do any wrapping, although we still pad the end of each cell with spaces using String.padEnd()
to help everything line up correctly.
// compose each data line
grid.forEach((row, i) => {
// skip the first line, we already have the headers
if (i > 0) {
lines.push(columnWidths.map((w, j) => row[j].padEnd(w)).join(' '));
}
});
// print out the lines
console.log(lines.join('\n'));
Here's what the program outputs to the console:
Gross
Caloric
Fixed Value at
Sample Moisture Volatile Carbon by Constant
Name Date Content Matter Difference Volume Ash
------- ----------- ---------- ----------- ----------- --------- ----------
X24-03 01-Nov-2018 4.85 wt. % 79.29 wt. % 15.62 wt. % 19985 J/g 0.25 wt. %
X24-02 31-Oct-2018 4.52 wt. % 80.91 wt. % 16.01 wt. % 20004 J/g 0.23 wt. %
X24-01 30-Oct-2018 4.68 wt. % 80.03 wt. % 15.89 wt. % 19996 J/g 0.24 wt. %
You can download the complete code here.
In the next installment, we'll update the program to make the output more readable by extracting the units out of the cells and putting them in the column headers.
Facebook recently began adding a fbclid
parameter to external links. Using the Neat URL Firefox Add-on and the Neat URL Chrome extension, you can easily remove these and other similar tracking parameters.
fbclid
is not in there by default, but I won't be surprised when the author adds it.fbclid
to the list. I added it in the middle with the other fb_*
parameters.Once you've completed the above steps, you can test by going to https://ianc.blog?fbclid=foo
. The extension modifies the request before it is sent to the server, so you should see the address bar show https://ianc.blog
right away. You're all set!