Mars Rovers VIII: The Grid Viz Solutions — Clojure

This is part of the Mars Rovers series.

Having now introduced The Grid Viz Problem and outlined my solution, let’s see the Clojure code.

The Code

;;; rover/grid.clj --- Mars Rovers' Grid Viz Problem
;;
;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>
;; SPDX-License-Identifier: EPL-2.0
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;


;;; Code
;;;; Namespace

(ns ^{:doc    "My solution to The Mars Rovers' Grid Viz Problem."
      :author "flandrew" :version "0.1.0" :updated "2026-04-18"}
    rover.grid
  (:require (rover   [core :refer :all])
            (clojure [string :as s])))


;;;; Extract path points and sides

(defn- stretch
  "Transform (x,y) → (2x,2y).
  Makes grid coordinates match indices of a vector of vectors (vov)."
  [[x y]] [(* 2 x) (* 2 y)])

(defn- midpoint
  "Given a vector with two points, return midpoint."
  [[[x1 y1] [x2 y2]]] [(/ (+ x1 x2) 2)
                       (/ (+ y1 y2) 2)])

(defn- path-sides
  "Convert vector of points into vector of sides.
  A side, here, is the midpoint of two consecutive non-overlapping points.
  It represents a segment of the rover's track, stretched (x,y) → (2x,2y)."
  [coll]
  (->> coll  dedupe  (mapv stretch)  (partition 2 1)  (mapv midpoint)))

(defn- side-type
  "Sort a side into horizontal or vertical."
  [pair]
  (case (mapv odd? pair) [true false] :Zs [false true] :Vs :NOT))

(defn- group-by-ZV
  "Group a vector of sides by horizontals and verticals."
  [coll]
  (group-by side-type coll))


;;;; Build vector of vectors
;;;;; Make new

(defn- vov-new
  "Create vector of vectors given xM and yM (grid's top-right coordinate).
  Initialize with empty-grid values: mzvp ~ mid, horiz, vert, point.
  Implicitly transforms (x,y) → (2x,2y).
  Visually, it's a 90° clockwise rotation of the grid, making coords match."
  [xM yM]
  (let [zm  (interpose :m (repeat (inc yM) :z))
        pv  (interpose :v (repeat (inc yM) :p))
        lol (interpose zm (repeat (inc xM) pv))]
    (mapv vec lol)))


;;;;; Update with rover

(defn- rover-make-vov-input-map
  "Given rover's init map, produce input map to modify vov."
  [imap]                                     ; Type    Use as
  (let [id     (->   imap :id str)           ; s     ← rover id
        amap   (->   imap rover-map-all)     ; vom
        lasth  (->   amap last :h keyword)   ; k     ← last heading
        points (->>  amap (mapv xy))         ; vov
        lastxy (-> points last)              ; v     ← last coord
        initxy (-> points first)             ; v     ← init coord
        sides  (-> points path-sides)        ; vov
        groupd (->  sides group-by-ZV)       ; m
        Zs     (-> groupd :Zs)               ; vov   ← horizontals' coords
        Vs     (-> groupd :Vs)]              ; vov   ← verticals'   coords
    {:begp (stretch initxy) :Zs Zs :endh lasth
     :endp (stretch lastxy) :Vs Vs :id   id}))

(defn- rover-make-vov-updates-from-map
  "Convert rover input map to a vector of updates."
  [{:keys [id begp endp endh Zs Vs]}]
  (into [[begp id] [endp endh]]
        `[~@(mapv #(vector % :Z) Zs)
          ~@(mapv #(vector % :V) Vs)]))

(defn- vov-update-with-rover
  "Update vector of vectors with rover's input map."
  [vov imap]
  (let [updates (rover-make-vov-updates-from-map imap)]
    (reduce #(apply assoc-in %1 %2) vov updates)))


;;;; Rotate for printing

(defn- vov-counterclockwise
  "Rotate vector of vectors 90° counterclockwise.
  This makes it ‘visually match’ the xy grid."
  [vov]
  (->> vov  (mapv reverse)  (apply mapv vector)))


;;;; Maybe customize display strings

(def default-smap
  "The default string values to replace placeholder keywords with.
  Everything except rover numbers can be customized."
  {:p "·" :z "   " :N "N" :E "E" ;s is used as intergrid separator
   :v " " :m "   " :S "S" :W "W" :s "="
   :V "│" :Z "───"})

(defn- make-smap
  "Merge a given smap with default-smap."
  [smap]
  (merge default-smap smap))

(defn- vov-keywords->strings
  "Replace vector of vectors' keywords with strings.
  If no map of keywords-to-strings is passed, use the default."
  ([vov]      (vov-keywords->strings vov {}))
  ([vov smap] (mapv #(replace (make-smap smap) %) vov)))


;;;; Make output string

(defn- vov->grid-str
  "Convert vector of vectors to output string.
  Assumed to have been CCW-rotated, and keywords replaced with strings."
  [vov]
  (->> vov  (map (comp s/trimr s/join))  (s/join "\n")))

(defn- rover-imap->grid-str
  "Given xM, yM, and rover's init map, return its grid string.
  Optional keywords-to-strings map may be provided."
  ([xM yM imap] (rover-imap->grid-str xM yM {} imap))
  ([xM yM smap imap]
   (let [vovin (rover-make-vov-input imap)]
     (-> (vov-new xM yM)
         (vov-update-with-rover vovin)
         (vov-keywords->strings (make-smap smap))
         (vov-counterclockwise)
         (vov->grid-str)))))

(defn- grid-strs-with-separators
  "Make horizontally separated string from vector of grid strings.
  An optional smap updates the default."
  ([xM coll] (grid-strs-with-separators xM {} coll))
  ([xM smap coll]
   (if (empty? coll) ""
       (let [{:keys
              [p m s]} (make-smap smap)
             width     (+ (* (count p) (inc xM))
                          (* (count m) xM))
             sline     (subs (s/join (repeat width s)) 0 width)]
         (s/join "\n" `(~sline ~@(interpose sline coll) ~sline))))))


;;;; Output grid

(defn output-grid
  "Given input string, produce rover grids.
  An optional map can be passed to change any of the default display strings.
  See variable ‘default-smap’."
  ([s] (output-grid {} s))
  ([smap s]
   (let [[xr yr & rovers] (parse-input s)
         [[_ xM] [_ yM]]  [xr yr]
         sm               (make-smap smap)]
     (->> rovers
          (mapv #(rover-imap->grid-str xM yM sm %))
          (grid-strs-with-separators   xM    sm)))))


;;; Local Variables:
;;  coding:                     utf-8
;;  indent-tabs-mode:           nil
;;  sentence-end-double-space:  nil
;;  outline-regexp:             ";;;;* "
;;  End:

;;; rover/grid.clj ends here

The Tests

;;; rover/grid_test.clj --- Mars Rovers' Grid Problem: Tests


;;; Code
;;;; Namespace

(ns rover.grid-test
  (:require [clojure.test :refer :all]
            [rover.grid   :refer :all]))


;;;; Tests
;;;;; Our sample input

(def sample-input
  "5 5
  1 2 N
  LMLMLMLMM
  3 3 E
  MMRMMRMRRM")

(deftest test-grid-sample-input
  (testing "Good input: given."

    ;; The default: exactly as displayed in the problem statement.
    (is (= (str "\n" (output-grid sample-input))
           "
=====================
·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·

·   N   ·   ·   ·   ·

·───1   ·   ·   ·   ·
│   │
·───·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·
=====================
·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·

·   ·   ·   2───·───·

·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·───E

·   ·   ·   ·   ·   ·
====================="))


    ;; Can we have different grid points and separator?
    ;; Sure!
    (is (= (str "\n" (output-grid {:p "•" :s "★"} sample-input))
           "
★★★★★★★★★★★★★★★★★★★★★
•   •   •   •   •   •

•   •   •   •   •   •

•   N   •   •   •   •

•───1   •   •   •   •
│   │
•───•   •   •   •   •

•   •   •   •   •   •
★★★★★★★★★★★★★★★★★★★★★
•   •   •   •   •   •

•   •   •   •   •   •

•   •   •   2───•───•

•   •   •   •   •   •

•   •   •   •   •───E

•   •   •   •   •   •
★★★★★★★★★★★★★★★★★★★★★"))


    ;; Can separators be patterned? Will width be adjusted?
    ;; Sure!
    (is (= (str "\n" (output-grid {:p "⋆" :s "★-☆~"} sample-input))
           "
★-☆~★-☆~★-☆~★-☆~★-☆~★
⋆   ⋆   ⋆   ⋆   ⋆   ⋆

⋆   ⋆   ⋆   ⋆   ⋆   ⋆

⋆   N   ⋆   ⋆   ⋆   ⋆

⋆───1   ⋆   ⋆   ⋆   ⋆
│   │
⋆───⋆   ⋆   ⋆   ⋆   ⋆

⋆   ⋆   ⋆   ⋆   ⋆   ⋆
★-☆~★-☆~★-☆~★-☆~★-☆~★
⋆   ⋆   ⋆   ⋆   ⋆   ⋆

⋆   ⋆   ⋆   ⋆   ⋆   ⋆

⋆   ⋆   ⋆   2───⋆───⋆

⋆   ⋆   ⋆   ⋆   ⋆   ⋆

⋆   ⋆   ⋆   ⋆   ⋆───E

⋆   ⋆   ⋆   ⋆   ⋆   ⋆
★-☆~★-☆~★-☆~★-☆~★-☆~★"))


    ;; Can we change the track lines as well? Maybe remove grid points?
    ;; Sure!
    (is (= (str "\n" (output-grid {:p " " :V "┆" :Z "┄┄┄"} sample-input))
           "
=====================




    N

 ┄┄┄1
┆   ┆
 ┄┄┄


=====================




            2┄┄┄ ┄┄┄



                 ┄┄┄E


====================="))

    ;; What about the headings? Could we use something more visual,
    ;; more universal, more language-agnostic?
    ;; Sure!
    (is (= (str "\n" (output-grid {:N "⮙" :E "⮚" :S "⮛" :W "⮘"} sample-input))
           "
=====================
·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·

·   ⮙   ·   ·   ·   ·

·───1   ·   ·   ·   ·
│   │
·───·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·
=====================
·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·   ·

·   ·   ·   2───·───·

·   ·   ·   ·   ·   ·

·   ·   ·   ·   ·───⮚

·   ·   ·   ·   ·   ·
====================="))))


;;;;; Some tiny cases

(def tiny-0a "0 0")
(def tiny-0b "1 1")
(def tiny-1a "1 1
              0 0 N
              MRMRMRML")  ; returns to origin
(def tiny-1b "1 1
              0 0 N
              ")  ; never leaves origin — doesn't even rotate
(def tiny-1c "2 1
              0 0 N \n MRM
              2 1 N \n LLMRMM")

(deftest test-grid-tiny
  (testing "Tiny."
    (is (= (output-grid tiny-0a) ""))
    (is (= (output-grid tiny-0b) ""))
    (is (= (str "\n" (output-grid tiny-1a)) "
=====
·───·
│   │
S───·
====="))

    (is (= (str "\n" (output-grid tiny-1b)) "
=====
·   ·

N   ·
====="))

    (is (= (str "\n" (output-grid tiny-1c)) "
=========
·───E   ·

1   ·   ·
=========
·   ·   2

W───·───·
========="))))


;;;;; How is Mars?

(def rover-news
  "3 1
   0 0 N \n M
   1 0 N \n MR
   2 0 N \n ML
   3 0 N \n MRR")

(deftest test-grid-news
  (testing "Hey, rovers — how is Mars? Send us some news!"
    (is (= (str "\n" (output-grid rover-news)) "
=============
N   ·   ·   ·

1   ·   ·   ·
=============
·   E   ·   ·

·   2   ·   ·
=============
·   ·   W   ·

·   ·   3   ·
=============
·   ·   ·   S

·   ·   ·   4
============="))))


;;;;; Rover ponders big questions

(def life
  "7 6
   1 5 S
   MMLMMLMMRRMMMMLMMMRRMMRMMRMMLMMLMMR")

(deftest test-grid-life
  (testing "Rover, what is the answer to life, the universe, and everything?"
    (is (= (str "\n" (output-grid life))
           "
=============================
·   ·   ·   ·   ·   ·   ·   ·

·   1   ·   ·   N───·───·   ·
    │       │           │
·   ·   ·   ·   ·   ·   ·   ·
    │       │           │
·   ·───·───·   ·───·───·   ·
            │   │
·   ·   ·   ·   ·   ·   ·   ·
            │   │
·   ·   ·   ·───·───·───·   ·

·   ·   ·   ·   ·   ·   ·   ·
============================="))))

(comment
  "And with this 7×6 grid we conclude our tests.")


;;;; Running them

(comment
  (run-tests) => {:test 4, :pass 12, :fail 0, :error 0, :type :summary})


;;; Local Variables:
;;  coding:                     utf-8
;;  indent-tabs-mode:           nil
;;  sentence-end-double-space:  nil
;;  outline-regexp:             ";;;;* "
;;  End:

;;; rover/grid_test.clj ends here

The Comments

Validating string dimensions — or not

My make-smap function was initially more complicated, as I had it do the extra work of validating the replacement strings:

;; Ensure monospace alignment
(def ^:private width count)
(def ^:private height (comp inc count #(re-seq #"\n" %)))
(defn- same-dim? [dim vec] (apply = (map dim vec)))
(defn- same-width?   [vec] (same-dim? width  vec))
(defn- same-height?  [vec] (same-dim? height vec))

(defn- make-smap
  "Merge a given smap with default-smap and then validate it.
  Validation consists of restricting the combination of display strings to
  those that keep grid alignment. It does not try to verify actual pixels — so
  you can still mess up display alignment if you use, say, CJK, emoji, etc."
  [smap]
  (let [{:keys [m v V p z Z N E S W] :as sm} (merge default-smap smap)]
    (if (and (same-height? [N E S W p z Z]) (same-height? [m v V])
             (same-width?  [N E S W p v V]) (same-width?  [m z Z])
             (= 1 (width p))) ; rover-id ≤ 9, and won't be customizable
      sm (throw (ex-info "Mismatch in string dimensions" sm)))))

And I ended up writing that in Elisp as well.

But then I thought that instead of throwing exceptions and restricting user input, better to keep it simple. So now it’s okay to enter some odd combination of replacement strings that makes the grid look unaligned. And if that doesn’t look good — well, then try another one!

So all the above became just this:

(defn- make-smap
  "Merge a given smap with default-smap."
  [smap]
  (merge default-smap smap))

Simpler. Better.

See next

Time to tackle it in Elisp.

Mars Rovers IX: The Grid Viz Solutions — Emacs Lisp

📆 2026-W16-7📆 2026-04-19