Skip to content

feat(plugin): route — UX polish (candidate picker, sidebar card, loading feedback) #345

@Kohei-Wada

Description

@Kohei-Wada

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.luarequire "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

  • Candidate picker — Nominatim returns up to 5 hits; we auto-pick [1]. For ambiguous queries ("Tokyo", "Akihabara station") show the full list in the palette and let the user select. Pattern: plugin/search/init.lua already does this for fly-to; the route plugin's prompt should follow.
  • Sidebar cardttymap.api.card.open panel with distance / duration / route summary. Template: plugin/traceroute.lua (build_lines + build_items + handle_key + close-on-q).
  • Loading-state visibility — currently silent between Enter and route-resolved (can be several seconds with Nominatim + OSRM). Either set is_loading = function() return state.geocode_job ~= nil or state.route_job ~= nil end on the palette so the spinner shows, or surface intermediate notifies ("Searching…" / "Routing…").
  • Flexible start pointhere is 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).
  • Turn-by-turn directions — pull routes[0].legs[0].steps[*] from OSRM (we don't request steps=true today), list in the sidebar card with per-step name + distance. Lets the route work as a directions panel, not just a line.
  • Multi-route preview — OSRM can return alternatives=true (up to 3 routes). Could overlay them dimmed and let the user pick.
  • Error UX — failures surface as ttymap.notify({ level = "warn" }) only. With a sidebar card we can show an inline error row instead of disappearing into the notification ring.
  • Keybind conflictR is arbitrary; lowercase r already taken by traceroute. Worth documenting the standard override pattern in ttymap/route.lua's header so users know the register_keybind re-binding dance.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions