Misc · Prototype · Jul 2026
Halftank.
A map for e-bikes, mopeds and small scooters that answers one question: with the range I have, where can I ride and still get home, on roads I'm actually allowed or willing to ride? Car maps don't know. It turns out one speed limit changes the answer by a factor of four.
LIMIT≤20SPEED
LIMIT25SPEED
LIMIT30SPEED
LIMIT35SPEED
LIMIT40SPEED
LIMIT45SPEED
LIMIT50+over the ceiling
- In reach at 35 mph
- 1,590 mi
- In reach at 45 mph
- 6,029 mi
- Road segments
- 686,219
- Speed limit mapped
- 36.6%
Why
It started with a practical question: how far can a 50cc scooter that tops out at 40 mph actually get around the East Valley? On a map, everything looks close. On the ground, nearly every through-road in the Phoenix suburbs is posted at 45, and a small scooter, an e-bike or a moped either isn't allowed on them or shouldn't be. The distance is fine; the roads are the problem.
Existing tools answer the wrong question. Isochrone maps show how far you get in 20 minutes by car, freeways included. Golf-cart routing apps follow 35 mph roads but don't know your range. Nobody draws range and road speed together, which is exactly the decision a small-vehicle rider makes before leaving.
How it works
Halftank is a single static web page: no server, no API key. You drop a pin, pick a vehicle, and it does four things in the browser.
- Pull the roads. It asks OpenStreetMap's public query service for every road in a box around the pin, with geometry and tags.
- Read the limit. Each segment gets a speed: the posted maxspeed if someone mapped it, otherwise a default by road type, flagged as a guess.
- Build a graph. Every point where segments meet becomes a node; every segment becomes an edge weighted by its length in miles.
- Search outward. Dijkstra's shortest-path algorithm expands from the pin, skips every edge above the vehicle's ceiling, and stops when the distance budget runs out. Round trip halves the budget.
// Shortest-path outward from the start, but some edges simply don't exist for you.
function reach(graph, start, budgetMiles, ceilingMph) {
const dist = new Map([[start, 0]]);
const pq = new MinHeap([[0, start]]);
while (pq.size) {
const [d, u] = pq.pop();
if (d > dist.get(u) || d > budgetMiles) continue;
for (const { to, miles, mph } of graph.get(u)) {
if (mph > ceilingMph) continue; // too fast for this vehicle: not an option
const nd = d + miles;
if (nd <= budgetMiles && nd < (dist.get(to) ?? Infinity)) {
dist.set(to, nd);
pq.push([nd, to]);
}
}
}
return dist; // every node you can reach, and how far it took
}
const budget = roundTrip ? range / 2 : range; // there and back without stoppingThe result isn't a circle with a radius. It's the real network: dead ends, canals, freeway crossings and all. Reachable roads are colored by their posted limit, and the legend is a row of speed-limit signs you can tap to hide a class. Roads you could reach by distance but not at your speed are drawn grey and dashed, so you can see what the ceiling costs you.
LIMIT≤20SPEED
LIMIT25SPEED
LIMIT30SPEED
LIMIT35SPEED
LIMIT40SPEED
LIMIT45SPEED
LIMIT50+over the ceiling
const PRESETS = {
ebike_class2: { range: 35, ceiling: 30 },
ebike_class3: { range: 45, ceiling: 35 },
moped: { range: 80, ceiling: 35 },
scooter_50cc: { range: 90, ceiling: 45 },
lsv: { range: 30, ceiling: 35 },
motorcycle: { range: 150, ceiling: 999 },
};What one speed limit costs
Two honest caveats sit inside those numbers. First, a lot of the "≤20" miles are parking aisles, alleys and service drives, which count as road in the data but not in a rider's head. Second, only 37% of road segments in this area have a speed limit in OpenStreetMap at all. The other 63% are guessed from the road's type. The page shows that percentage live, because it's the honest data-quality verdict for whatever area you're looking at.
// Most roads have no posted limit in OpenStreetMap. Fall back to the road's class.
const ASSUMED = {
motorway: 65, trunk: 55, primary: 45, secondary: 40, tertiary: 35,
unclassified: 30, residential: 25, living_street: 15, service: 15,
};
function mph(tags) {
const m = /(\d+)/.exec(tags.maxspeed || "");
if (m) return { mph: +m[1], mapped: true };
return { mph: ASSUMED[tags.highway] ?? 25, mapped: false }; // counted toward "% guessed"
}Where it stands
It's a working prototype. The open problems are the ones the numbers point to: better speed-limit data (city and county GIS layers publish posted limits; OpenStreetMap is incomplete), one-way streets (the graph is currently undirected), and junction matching by OpenStreetMap node instead of rounded coordinates. The query service also slows down past about 15 miles out, so a public version would need its own routing backend.
What I learned
- Constraints beat distance. For a small vehicle, the question isn't how far but which roads. One rule (no roads over 35) removed three-quarters of the reachable network.
- Show the cost. Drawing the forbidden roads in grey turned an abstract setting into something you can see.
- Publish the data quality. A map built 63% on guesses should say so on the screen.
- Grids make diamonds. On a street grid, "ten miles" is a diamond, not a circle.
Map data © OpenStreetMap contributors. Figures computed Oct 18, 2026 from a 20-mile round trip starting in downtown Mesa, AZ, using Halftank's own reach algorithm. Questions: Discord.