Summary
The route plugin lands driving directions from the user's current location (ttymap.location → IP geoip) to a typed destination (Nominatim forward-geocode → OSRM public demo). MVP works end-to-end: palette → type → polyline draws across frames. The remaining work is UX polish.
Reference implementation
Files:
ttymap-tui/runtime/lua/plugin/route.lua — main plugin (palette prompt, tick driver, OSRM → polyline)
ttymap-tui/runtime/lua/ttymap/route.lua — config holder (URL builder, profile, colours, marker glyphs)
ttymap-tui/runtime/init.lua — require "plugin.route" line
Activation:
R keybind → to> palette prompt
: → Route from here (same prompt as above)
: → Clear route (drops the polyline + cancels in-flight jobs)
Pipeline:
to> <text>
↓ Nominatim forward-geocode (1st hit)
↓ ttymap.location → from = here
↓ OSRM public demo (router.project-osrm.org)
/route/v1/driving/{from_lon},{from_lat};{to_lon},{to_lat}
?overview=full&geometries=geojson
↓ payload.routes[1].geometry.coordinates → state.polyline
↓ on_tick redraws map:polyline(state.polyline, color) every frame
State machine is geocode_job → loc.get(cb) → route_job → polyline; each step drains via try_take() on the shared on_tick handler. Clear route cancels in-flight jobs.
Colour
Default ttymap.route.color = 46 (#00FF00, xterm-256 pure bright green). Picked after iterating from accent → deep blue (18) → bright green (46) for visibility against both DARK theme (bg=16) and water/landuse fills. Override:
require("ttymap.route").color = 82 -- lime
require("ttymap.route").color = 118 -- chartreuse
require("ttymap.route").color = 21 -- bright blue (#0000FF)
Markers: start_glyph = ◉, end_glyph = ★, both accent_alt by default.
Open UX gaps
Known engine-side limit (not in scope here)
map:polyline over a saturated tile fill (water, dense forest) renders as a 1-cell-wide ⣿ swatch in route colour through the fill — Braille is one-fg-per-cell, per-sub-pixel colour mixing isn't expressible. Accepted trade-off; doesn't affect typical driving routes (no long water crossings). High zoom dissolves the issue because route + fill stop sharing cells.
Related
Reference implementation (snapshot, not committed)
The MVP was prototyped locally and then removed. Pasting the source here so a future rebuild starts from a known-working baseline.
ttymap-tui/runtime/init.lua (insert in the bundled plugins block, alphabetical)
require "plugin.quake"
require "plugin.route" -- ← add this line
require "plugin.satellite"
ttymap-tui/runtime/lua/ttymap/route.lua
-- ttymap.route — config holder for the bundled `route` plugin.
--
-- Same nvim-style seam as ttymap.traceroute: the plugin reads this
-- module on every invocation, so init.lua pre-pass mutations apply
-- without restart:
--
-- local r = require("ttymap.route")
-- r.profile = "cycling"
-- r.color = 220 -- yellow line
-- r.osrm_url = function(profile, from_lon, from_lat, to_lon, to_lat)
-- return string.format(
-- "http://localhost:5000/route/v1/%s/%f,%f;%f,%f?overview=full&geometries=geojson",
-- profile, from_lon, from_lat, to_lon, to_lat)
-- end
--
-- The default `osrm_url` targets router.project-osrm.org, the OSRM
-- public demo. It's for dev/demo use only — for sustained use, point
-- this at your own OSRM instance or an alternative (ORS, Valhalla).
-- The demo only serves the `driving` profile reliably; the others
-- may 404 depending on the day.
return {
-- (profile, from_lon, from_lat, to_lon, to_lat) → URL.
-- Note the lon-first ordering — OSRM is lon,lat throughout.
osrm_url = function(profile, from_lon, from_lat, to_lon, to_lat)
return string.format(
"https://router.project-osrm.org/route/v1/%s/%.6f,%.6f;%.6f,%.6f?overview=full&geometries=geojson",
profile, from_lon, from_lat, to_lon, to_lat)
end,
-- OSRM profile. Public demo supports "driving"; "cycling" / "foot"
-- depend on the demo's current configuration. Self-host or pick a
-- different provider for reliable non-driving routing.
profile = "driving",
-- Route polyline colour. xterm-256 index or palette keyword
-- ("accent", "accent_alt", "road", "muted").
-- 46 = pure bright green (#00FF00). 82 lime, 118 chartreuse,
-- 18/21 deep/bright blue.
color = 46,
-- Start / end marker styling.
start_glyph = "◉",
end_glyph = "★",
start_color = "accent_alt",
end_color = "accent_alt",
}
ttymap-tui/runtime/lua/plugin/route.lua
-- route — driving directions from the user's current location to a
-- destination address.
--
-- Flow:
-- 1. `/Route` (palette) or `R` keybind opens a `to> ` prompt.
-- 2. On Enter, Nominatim geocodes the destination (first hit).
-- 3. `ttymap.location` resolves "here" (IP geoip, cached).
-- 4. OSRM computes the polyline (driving profile by default).
-- 5. The polyline lives across frames — re-drawn each tick — until
-- the user issues a new /Route or clears it.
--
-- Endpoints / profile / colours are in `ttymap.route`. Override via
-- `require("ttymap.route").<field> = ...` in init.lua.
local config = require "ttymap.route"
local loc = require "ttymap.location"
local nominatim = require "plugin.search.nominatim"
------------------------------------------------------------------
-- Session state — at most one active route at a time. New requests
-- abandon the previous one (cancel in-flight jobs, drop the
-- polyline). `polyline` outlives `from`/`dest` only so the on_tick
-- redraw path can keep painting while the user pans / zooms.
------------------------------------------------------------------
local state = {
-- Pending text the user entered, kept for error messages.
query = nil,
-- Async jobs (mutually exclusive — at most one in flight).
geocode_job = nil,
route_job = nil,
-- Resolved waypoints.
from = nil, -- { lon, lat }
dest = nil, -- { name, lon, lat }
-- Drawn artefacts.
polyline = nil, -- { { lon, lat }, ... }
distance_m = nil,
duration_s = nil,
}
local tick_handle = nil
local function cancel_jobs()
if state.geocode_job then state.geocode_job:cancel(); state.geocode_job = nil end
if state.route_job then state.route_job:cancel(); state.route_job = nil end
end
local function clear()
cancel_jobs()
state.query = nil
state.from = nil
state.dest = nil
state.polyline = nil
state.distance_m = nil
state.duration_s = nil
end
------------------------------------------------------------------
-- Step 3: OSRM call. Lon-first throughout.
------------------------------------------------------------------
local function start_routing()
if not (state.from and state.dest) then return end
local url = config.osrm_url(
config.profile,
state.from.lon, state.from.lat,
state.dest.lon, state.dest.lat)
state.route_job = ttymap.http:fetch(url)
end
------------------------------------------------------------------
-- Step 2: location resolve. `loc.get` is async — the callback may
-- fire on this tick (cache hit) or several ticks later (network).
------------------------------------------------------------------
local function resolve_location_then_route()
loc.get(function(lat, lon)
if not lat then
ttymap.notify("Route: cannot resolve current location",
{ level = "warn" })
return
end
state.from = { lat = lat, lon = lon }
start_routing()
end)
end
------------------------------------------------------------------
-- Tick driver. Drains each job in turn and paints the route once
-- the polyline is in hand. Idempotent — keeps running once
-- registered so a future /Route picks up where it left off without
-- re-subscribing.
------------------------------------------------------------------
local function drain_geocode()
if not state.geocode_job then return end
local body = state.geocode_job:try_take()
if not body then return end
state.geocode_job = nil
local payload = ttymap.json:parse(body)
if not payload then
ttymap.notify("Route: Nominatim response unparseable",
{ level = "warn" })
return
end
local results = nominatim.parse(payload)
if #results == 0 then
ttymap.notify(string.format(
"Route: no match for \"%s\"", state.query or "?"),
{ level = "warn" })
return
end
state.dest = results[1]
resolve_location_then_route()
end
local function drain_route()
if not state.route_job then return end
local body = state.route_job:try_take()
if not body then return end
state.route_job = nil
local payload = ttymap.json:parse(body)
if not payload or payload.code ~= "Ok" then
ttymap.notify(string.format(
"Route: OSRM returned %s",
(payload and payload.code) or "unparseable response"),
{ level = "warn" })
return
end
local r = payload.routes and payload.routes[1]
if not (r and r.geometry and type(r.geometry.coordinates) == "table") then
ttymap.notify("Route: no route in OSRM response",
{ level = "warn" })
return
end
state.polyline = r.geometry.coordinates
state.distance_m = r.distance
state.duration_s = r.duration
ttymap.notify(string.format(
"Route → %s (%.1f km, %d min)",
state.dest.name,
(state.distance_m or 0) / 1000,
math.floor((state.duration_s or 0) / 60)))
end
local function draw(map)
if state.polyline then
map:polyline(state.polyline, config.color)
end
if state.from then
map:point(state.from.lon, state.from.lat,
config.start_glyph, config.start_color)
end
if state.dest then
map:point(state.dest.lon, state.dest.lat,
config.end_glyph, config.end_color)
end
end
local function on_tick(map)
drain_geocode()
drain_route()
draw(map)
end
local function ensure_tick()
if not tick_handle then
tick_handle = ttymap.api.frame.on_tick(on_tick)
end
end
------------------------------------------------------------------
-- Entry — palette prompt + activation.
------------------------------------------------------------------
local function start(query)
clear()
state.query = query
state.geocode_job = ttymap.http:fetch(nominatim.url(query))
ensure_tick()
ttymap.notify("Route → " .. query)
end
local prompt_state = { query = "" }
local function open_prompt()
prompt_state.query = ""
ttymap.api.palette.open({
prompt = "to> ",
submit_mode = "on_enter",
filter = function(query)
prompt_state.query = query:match("^%s*(.-)%s*$") or ""
end,
items = function()
if prompt_state.query == "" then return {} end
return { { label = "Route to " .. prompt_state.query,
hint = "Enter" } }
end,
execute = function(_idx)
if prompt_state.query ~= "" then
start(prompt_state.query)
end
end,
is_loading = function() return false end,
})
end
ttymap.register_keybind("R", open_prompt)
ttymap.register_palette_command({
label = "Route from here",
hint = "R",
invoke = open_prompt,
})
ttymap.register_palette_command({
label = "Clear route",
invoke = clear,
})
Verified state at snapshot
cargo build clean, cargo test 215/215 pass (bundled-plugin scanner picked up the new file via the require "plugin.route" line)
- Manual: green polyline draws from current location to typed destination, marker glyphs at endpoints, notify with distance/duration on completion
- Files removed afterwards — to rebuild, recreate the two
.lua files and re-add the require "plugin.route" line in runtime/init.lua
Summary
The
routeplugin lands driving directions from the user's current location (ttymap.location→ IP geoip) to a typed destination (Nominatim forward-geocode → OSRM public demo). MVP works end-to-end: palette → type → polyline draws across frames. The remaining work is UX polish.Reference implementation
Files:
ttymap-tui/runtime/lua/plugin/route.lua— main plugin (palette prompt, tick driver, OSRM → polyline)ttymap-tui/runtime/lua/ttymap/route.lua— config holder (URL builder, profile, colours, marker glyphs)ttymap-tui/runtime/init.lua—require "plugin.route"lineActivation:
Rkeybind →to>palette prompt:→Route from here(same prompt as above):→Clear route(drops the polyline + cancels in-flight jobs)Pipeline:
State machine is
geocode_job → loc.get(cb) → route_job → polyline; each step drains viatry_take()on the sharedon_tickhandler.Clear routecancels in-flight jobs.Colour
Default
ttymap.route.color = 46(#00FF00, xterm-256 pure bright green). Picked after iterating from accent → deep blue (18) → bright green (46) for visibility against both DARK theme (bg=16) and water/landuse fills. Override:Markers:
start_glyph = ◉,end_glyph = ★, bothaccent_altby default.Open UX gaps
[1]. For ambiguous queries ("Tokyo", "Akihabara station") show the full list in the palette and let the user select. Pattern:plugin/search/init.luaalready does this for fly-to; the route plugin's prompt should follow.ttymap.api.card.openpanel with distance / duration / route summary. Template:plugin/traceroute.lua(build_lines+build_items+handle_key+ close-on-q).is_loading = function() return state.geocode_job ~= nil or state.route_job ~= nil endon the palette so the spinner shows, or surface intermediate notifies ("Searching…" / "Routing…").hereis the only origin. Options to support::Route <from> to <to>(two-arg parse), "from map centre", two map clicks (would need a mouse-click event hook plugins can subscribe to — may be a separate enabler).routes[0].legs[0].steps[*]from OSRM (we don't requeststeps=truetoday), list in the sidebar card with per-stepname+distance. Lets the route work as a directions panel, not just a line.alternatives=true(up to 3 routes). Could overlay them dimmed and let the user pick.ttymap.notify({ level = "warn" })only. With a sidebar card we can show an inline error row instead of disappearing into the notification ring.Ris arbitrary; lowercaseralready taken by traceroute. Worth documenting the standard override pattern inttymap/route.lua's header so users know theregister_keybindre-binding dance.Known engine-side limit (not in scope here)
map:polylineover a saturated tile fill (water, dense forest) renders as a 1-cell-wide⣿swatch in route colour through the fill — Braille is one-fg-per-cell, per-sub-pixel colour mixing isn't expressible. Accepted trade-off; doesn't affect typical driving routes (no long water crossings). High zoom dissolves the issue because route + fill stop sharing cells.Related
ttymap.geocode— when that lib lands, the geocode step here should switch to it (cache hits + shared withinfo/search/wiki).Reference implementation (snapshot, not committed)
The MVP was prototyped locally and then removed. Pasting the source here so a future rebuild starts from a known-working baseline.
ttymap-tui/runtime/init.lua(insert in the bundled plugins block, alphabetical)ttymap-tui/runtime/lua/ttymap/route.luattymap-tui/runtime/lua/plugin/route.luaVerified state at snapshot
cargo buildclean,cargo test215/215 pass (bundled-plugin scanner picked up the new file via therequire "plugin.route"line).luafiles and re-add therequire "plugin.route"line inruntime/init.lua