diff --git a/package-lock.json b/package-lock.json
index b0706c7..3deb7a6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -4,16 +4,242 @@
 	"lockfileVersion": 1,
 	"requires": true,
 	"dependencies": {
+		"@iarna/toml": {
+			"version": "2.2.3",
+			"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.3.tgz",
+			"integrity": "sha512-FmuxfCuolpLl0AnQ2NHSzoUKWEJDFl63qXjzdoWBVyFCXzMGm1spBzk7LeHNoVCiWCF7mRVms9e6jEV9+MoPbg=="
+		},
 		"applause-cli": {
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/applause-cli/-/applause-cli-1.2.0.tgz",
 			"integrity": "sha512-JPCHkBzaghitUPYWK4Jm6YNUklJIBjhlvzU3WLswjvjolMyQN8WVWR8qrGmMbs1skej2p/ZINisZVVvmuEFkog=="
 		},
+		"arr-flatten": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz",
+			"integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg=="
+		},
+		"atob-lite": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/atob-lite/-/atob-lite-2.0.0.tgz",
+			"integrity": "sha1-D+9a1G8b16hQLGVyfwNn1e5D1pY="
+		},
+		"bmp-js": {
+			"version": "0.1.0",
+			"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
+			"integrity": "sha1-4Fpj95amwf8l9Hcex62twUjAcjM="
+		},
+		"chroma-js": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/chroma-js/-/chroma-js-2.1.0.tgz",
+			"integrity": "sha512-uiRdh4ZZy+UTPSrAdp8hqEdVb1EllLtTHOt5TMaOjJUvi+O54/83Fc5K2ld1P+TJX+dw5B+8/sCgzI6eaur/lg==",
+			"requires": {
+				"cross-env": "^6.0.3"
+			}
+		},
+		"clamp": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/clamp/-/clamp-1.0.1.tgz",
+			"integrity": "sha1-ZqDmQBGBbjcZaCj9yMjBRzEshjQ="
+		},
+		"color-id": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/color-id/-/color-id-1.1.0.tgz",
+			"integrity": "sha512-2iRtAn6dC/6/G7bBIo0uupVrIne1NsQJvJxZOBCzQOfk7jRq97feaDZ3RdzuHakRXXnHGNwglto3pqtRx1sX0g==",
+			"requires": {
+				"clamp": "^1.0.1"
+			}
+		},
+		"compute-dims": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/compute-dims/-/compute-dims-1.1.0.tgz",
+			"integrity": "sha512-YHMiIKjH/8Eom8zATk3g8/lH3HxGCZcVQyEfEoVrfWI7od/WRpTgRGShnei3jArYSx77mQqPxZNokjGHCdLfxg==",
+			"requires": {
+				"utils-copy": "^1.0.0",
+				"validate.io-array": "^1.0.6",
+				"validate.io-matrix-like": "^1.0.2",
+				"validate.io-ndarray-like": "^1.0.0",
+				"validate.io-positive-integer": "^1.0.0"
+			}
+		},
+		"const-max-uint32": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/const-max-uint32/-/const-max-uint32-1.0.2.tgz",
+			"integrity": "sha1-8Am7YjDmeO2HTdLWqc2ePL+rtnY="
+		},
+		"const-pinf-float64": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/const-pinf-float64/-/const-pinf-float64-1.0.0.tgz",
+			"integrity": "sha1-9u+w15+cCYbT558pI6v5twtj1yY="
+		},
+		"cross-env": {
+			"version": "6.0.3",
+			"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-6.0.3.tgz",
+			"integrity": "sha512-+KqxF6LCvfhWvADcDPqo64yVIB31gv/jQulX2NGzKS/g3GEVz6/pt4wjHFtFWsHMddebWD/sDthJemzM4MaAag==",
+			"requires": {
+				"cross-spawn": "^7.0.0"
+			}
+		},
+		"cross-spawn": {
+			"version": "7.0.1",
+			"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.1.tgz",
+			"integrity": "sha512-u7v4o84SwFpD32Z8IIcPZ6z1/ie24O6RU3RbtL5Y316l3KuHVPx9ItBgWQ6VlfAFnRnTtMUrsQ9MUUTuEZjogg==",
+			"requires": {
+				"path-key": "^3.1.0",
+				"shebang-command": "^2.0.0",
+				"which": "^2.0.1"
+			}
+		},
+		"dtype": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/dtype/-/dtype-2.0.0.tgz",
+			"integrity": "sha1-zQUjI84GFETs0uj1dI9popvihDQ="
+		},
+		"file-type": {
+			"version": "10.11.0",
+			"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
+			"integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw=="
+		},
+		"flatten-vertex-data": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/flatten-vertex-data/-/flatten-vertex-data-1.0.2.tgz",
+			"integrity": "sha512-BvCBFK2NZqerFTdMDgqfHBwxYWnxeCkwONsw6PvBMcUXqo8U/KDWwmXhqx1x2kLIg7DqIsJfOaJFOmlua3Lxuw==",
+			"requires": {
+				"dtype": "^2.0.0"
+			}
+		},
+		"flip-pixels": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/flip-pixels/-/flip-pixels-1.0.2.tgz",
+			"integrity": "sha512-oXbJGbjDnfJRWPC7Va38EFhd+A8JWE5/hCiKcK8qjCdbLj9DTpsq6MEudwpRTH+V4qq+Jw7d3pUgQdSr3x3mTA=="
+		},
+		"fs-extra": {
+			"version": "8.1.0",
+			"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz",
+			"integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==",
+			"requires": {
+				"graceful-fs": "^4.2.0",
+				"jsonfile": "^4.0.0",
+				"universalify": "^0.1.0"
+			}
+		},
+		"graceful-fs": {
+			"version": "4.2.3",
+			"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
+			"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
+		},
+		"iconv-lite": {
+			"version": "0.5.1",
+			"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.5.1.tgz",
+			"integrity": "sha512-ONHr16SQvKZNSqjQT9gy5z24Jw+uqfO02/ngBSBoqChZ+W8qXX7GPRa1RoUnzGADw8K63R1BXUMzarCVQBpY8Q==",
+			"requires": {
+				"safer-buffer": ">= 2.1.2 < 3"
+			}
+		},
+		"image-encode": {
+			"version": "1.3.0",
+			"resolved": "https://registry.npmjs.org/image-encode/-/image-encode-1.3.0.tgz",
+			"integrity": "sha512-2SzuDscMQGK38jGiVMSuygquz2z3DnW4Smget/LipWfqvEnc7+L/qbaQAPDtzi9hsHqKpGGU22SUjl6G8TprJg==",
+			"requires": {
+				"bmp-js": "^0.1.0",
+				"image-palette": "^2.0.1",
+				"image-type": "^3.0.0",
+				"is-plain-obj": "^1.1.0",
+				"jpeg-js": "^0.3.4",
+				"next-pow-2": "^1.0.0",
+				"object-assign": "^4.1.1",
+				"omggif": "^1.0.9",
+				"pngjs": "^3.3.3",
+				"pxls": "^2.3.1",
+				"to-array-buffer": "^3.0.0",
+				"utif": "^2.0.1"
+			}
+		},
+		"image-palette": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/image-palette/-/image-palette-2.1.0.tgz",
+			"integrity": "sha512-3ImSEWD26+xuQFdP0RWR4WSXadZwvgrFhjGNpMEapTG1tf2XrBFS2dlKK5hNgH4UIaSQlSUFRn1NeA+zULIWbQ==",
+			"requires": {
+				"color-id": "^1.1.0",
+				"pxls": "^2.0.0",
+				"quantize": "^1.0.2"
+			}
+		},
+		"image-type": {
+			"version": "3.1.0",
+			"resolved": "https://registry.npmjs.org/image-type/-/image-type-3.1.0.tgz",
+			"integrity": "sha512-edYRXKQ3WD2yHXFGUbwoJVn5v7j1A6Z505uZUYIfzCwOOhPGLYSc3VOucF9fqbsaUbgb37DdjOU+WV4uo7ZooQ==",
+			"requires": {
+				"file-type": "^10.9.0"
+			}
+		},
+		"is-base64": {
+			"version": "0.1.0",
+			"resolved": "https://registry.npmjs.org/is-base64/-/is-base64-0.1.0.tgz",
+			"integrity": "sha512-WRRyllsGXJM7ZN7gPTCCQ/6wNPTRDwiWdPK66l5sJzcU/oOzcIcRRf0Rux8bkpox/1yjt0F6VJRsQOIG2qz5sg=="
+		},
+		"is-blob": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/is-blob/-/is-blob-2.1.0.tgz",
+			"integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw=="
+		},
+		"is-browser": {
+			"version": "2.1.0",
+			"resolved": "https://registry.npmjs.org/is-browser/-/is-browser-2.1.0.tgz",
+			"integrity": "sha512-F5rTJxDQ2sW81fcfOR1GnCXT6sVJC104fCyfj+mjpwNEwaPYSn5fte5jiHmBg3DHsIoL/l8Kvw5VN5SsTRcRFQ=="
+		},
+		"is-buffer": {
+			"version": "2.0.4",
+			"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.4.tgz",
+			"integrity": "sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A=="
+		},
+		"is-float-array": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/is-float-array/-/is-float-array-1.0.0.tgz",
+			"integrity": "sha512-4ew1Sx6B6kEAl3T3NOM0yB94J3NZnBdNt4paw0e8nY73yHHTeTEhyQ3Lj7EQEnv5LD+GxNTaT4L46jcKjjpLiQ=="
+		},
+		"is-plain-obj": {
+			"version": "1.1.0",
+			"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+			"integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4="
+		},
+		"isexe": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+			"integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA="
+		},
+		"jpeg-js": {
+			"version": "0.3.7",
+			"resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.3.7.tgz",
+			"integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ=="
+		},
+		"jsonfile": {
+			"version": "4.0.0",
+			"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
+			"integrity": "sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss=",
+			"requires": {
+				"graceful-fs": "^4.1.6"
+			}
+		},
 		"mgrs": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
 			"integrity": "sha1-+5FYjnjJACVnI5XLQLJffNatGCk="
 		},
+		"nexline": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/nexline/-/nexline-1.2.0.tgz",
+			"integrity": "sha512-Xie4Lj/x0Lh9ntY/0n1zUZL4V4tvUvReE93iR3DxzZK8LVx15rmzAmeiaFuE0dzS1njS33n2+HRfgOBc5R7nfw==",
+			"requires": {
+				"fs-extra": "^8.1.0",
+				"iconv-lite": "^0.5.0"
+			}
+		},
+		"next-pow-2": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/next-pow-2/-/next-pow-2-1.0.0.tgz",
+			"integrity": "sha1-y1wvHa4EDFbN1c2h3FxqOjOPQ2c="
+		},
 		"nnng": {
 			"version": "1.0.0",
 			"resolved": "https://registry.npmjs.org/nnng/-/nnng-1.0.0.tgz",
@@ -22,6 +248,36 @@
 				"proj4": "^2.3.10"
 			}
 		},
+		"object-assign": {
+			"version": "4.1.1",
+			"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+			"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+		},
+		"object-keys": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+			"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
+		},
+		"omggif": {
+			"version": "1.0.10",
+			"resolved": "https://registry.npmjs.org/omggif/-/omggif-1.0.10.tgz",
+			"integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="
+		},
+		"pako": {
+			"version": "1.0.11",
+			"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+			"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
+		},
+		"path-key": {
+			"version": "3.1.1",
+			"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+			"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="
+		},
+		"pngjs": {
+			"version": "3.4.0",
+			"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-3.4.0.tgz",
+			"integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w=="
+		},
 		"proj4": {
 			"version": "2.6.0",
 			"resolved": "https://registry.npmjs.org/proj4/-/proj4-2.6.0.tgz",
@@ -31,14 +287,231 @@
 				"wkt-parser": "^1.2.0"
 			}
 		},
-		"terrain50": {
+		"pxls": {
+			"version": "2.3.2",
+			"resolved": "https://registry.npmjs.org/pxls/-/pxls-2.3.2.tgz",
+			"integrity": "sha512-pQkwgbLqWPcuES5iEmGa10OlCf5xG0blkIF3dg7PpRZShbTYcvAdfFfGL03SMrkaSUaa/V0UpN9HWg40O2AIIw==",
+			"requires": {
+				"arr-flatten": "^1.1.0",
+				"compute-dims": "^1.1.0",
+				"flip-pixels": "^1.0.2",
+				"is-browser": "^2.1.0",
+				"is-buffer": "^2.0.3",
+				"to-uint8": "^1.4.1"
+			}
+		},
+		"quantize": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/quantize/-/quantize-1.0.2.tgz",
+			"integrity": "sha1-0lrCAKd7bXD0ASfKFxoQ4zyFRt4="
+		},
+		"regex-regex": {
 			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/terrain50/-/terrain50-1.0.0.tgz",
-			"integrity": "sha512-qEw69Sk9F6AiTmAa+mpElSRN5wScscxRCjN/6sie42QXlUOdqNLxSbWOnKSLGiBGg1BH6/Uhbwf3YjP5YFvpwQ==",
+			"resolved": "https://registry.npmjs.org/regex-regex/-/regex-regex-1.0.0.tgz",
+			"integrity": "sha1-kEih6uuHD01IDavHb8Qs3MC8OnI="
+		},
+		"safer-buffer": {
+			"version": "2.1.2",
+			"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+			"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
+		},
+		"shebang-command": {
+			"version": "2.0.0",
+			"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+			"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+			"requires": {
+				"shebang-regex": "^3.0.0"
+			}
+		},
+		"shebang-regex": {
+			"version": "3.0.0",
+			"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+			"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="
+		},
+		"string-to-arraybuffer": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/string-to-arraybuffer/-/string-to-arraybuffer-1.0.2.tgz",
+			"integrity": "sha512-DaGZidzi93dwjQen5I2osxR9ERS/R7B1PFyufNMnzhj+fmlDQAc1DSDIJVJhgI8Oq221efIMbABUBdPHDRt43Q==",
+			"requires": {
+				"atob-lite": "^2.0.0",
+				"is-base64": "^0.1.0"
+			}
+		},
+		"terrain50": {
+			"version": "1.3.0",
+			"resolved": "https://registry.npmjs.org/terrain50/-/terrain50-1.3.0.tgz",
+			"integrity": "sha512-BkQMAY6W67FsE1yWr24yHBhd46lQCdwON1/pMkZsLTWl1EYiaSPCNu+QCO4GV/7E8b9IXgiPUQpOqtiA5sZ6BQ==",
 			"requires": {
 				"nnng": "^1.0.0"
 			}
 		},
+		"to-array-buffer": {
+			"version": "3.2.0",
+			"resolved": "https://registry.npmjs.org/to-array-buffer/-/to-array-buffer-3.2.0.tgz",
+			"integrity": "sha512-zN33mwi0gpL+7xW1ITLfJ48CEj6ZQW0ZAP0MU+2W3kEY0PAIncyuxmD4OqkUVhPAbTP7amq9j/iwvZKYS+lzSQ==",
+			"requires": {
+				"flatten-vertex-data": "^1.0.2",
+				"is-blob": "^2.0.1",
+				"string-to-arraybuffer": "^1.0.0"
+			}
+		},
+		"to-uint8": {
+			"version": "1.4.1",
+			"resolved": "https://registry.npmjs.org/to-uint8/-/to-uint8-1.4.1.tgz",
+			"integrity": "sha512-o+ochsMlTZyucbww8It401FC2Rx+OP2RpDeYbA6h+y9HgedDl1UjdsJ9CmzKEG7AFP9es5PmJ4eDWeeeXihESg==",
+			"requires": {
+				"arr-flatten": "^1.1.0",
+				"clamp": "^1.0.1",
+				"is-base64": "^0.1.0",
+				"is-float-array": "^1.0.0",
+				"to-array-buffer": "^3.0.0"
+			}
+		},
+		"type-name": {
+			"version": "2.0.2",
+			"resolved": "https://registry.npmjs.org/type-name/-/type-name-2.0.2.tgz",
+			"integrity": "sha1-7+fUEj2KxSr/9/QMfk3sUmYAj7Q="
+		},
+		"universalify": {
+			"version": "0.1.2",
+			"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+			"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="
+		},
+		"utif": {
+			"version": "2.0.1",
+			"resolved": "https://registry.npmjs.org/utif/-/utif-2.0.1.tgz",
+			"integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==",
+			"requires": {
+				"pako": "^1.0.5"
+			}
+		},
+		"utils-copy": {
+			"version": "1.1.1",
+			"resolved": "https://registry.npmjs.org/utils-copy/-/utils-copy-1.1.1.tgz",
+			"integrity": "sha1-biuXmCqozXPhGCo+b4vsPA9AWKc=",
+			"requires": {
+				"const-pinf-float64": "^1.0.0",
+				"object-keys": "^1.0.9",
+				"type-name": "^2.0.0",
+				"utils-copy-error": "^1.0.0",
+				"utils-indexof": "^1.0.0",
+				"utils-regex-from-string": "^1.0.0",
+				"validate.io-array": "^1.0.3",
+				"validate.io-buffer": "^1.0.1",
+				"validate.io-nonnegative-integer": "^1.0.0"
+			}
+		},
+		"utils-copy-error": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/utils-copy-error/-/utils-copy-error-1.0.1.tgz",
+			"integrity": "sha1-eR3jk8DwmJCv1Z88vqY18HmpT6U=",
+			"requires": {
+				"object-keys": "^1.0.9",
+				"utils-copy": "^1.1.0"
+			}
+		},
+		"utils-indexof": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/utils-indexof/-/utils-indexof-1.0.0.tgz",
+			"integrity": "sha1-IP6r8J7xAYtSNkPoOA57yD7GG1w=",
+			"requires": {
+				"validate.io-array-like": "^1.0.1",
+				"validate.io-integer-primitive": "^1.0.0"
+			}
+		},
+		"utils-regex-from-string": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/utils-regex-from-string/-/utils-regex-from-string-1.0.0.tgz",
+			"integrity": "sha1-/hopCfjeD/DVGCyA+8ZU1qaH0Yk=",
+			"requires": {
+				"regex-regex": "^1.0.0",
+				"validate.io-string-primitive": "^1.0.0"
+			}
+		},
+		"validate.io-array": {
+			"version": "1.0.6",
+			"resolved": "https://registry.npmjs.org/validate.io-array/-/validate.io-array-1.0.6.tgz",
+			"integrity": "sha1-W1osr9j4uFq7L4hroVPy2Tond00="
+		},
+		"validate.io-array-like": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/validate.io-array-like/-/validate.io-array-like-1.0.2.tgz",
+			"integrity": "sha1-evn363tRcVvrIhVmjsXM5U+t21o=",
+			"requires": {
+				"const-max-uint32": "^1.0.2",
+				"validate.io-integer-primitive": "^1.0.0"
+			}
+		},
+		"validate.io-buffer": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/validate.io-buffer/-/validate.io-buffer-1.0.2.tgz",
+			"integrity": "sha1-hS1nNAIZFNXROvwyUxdh43IO1E4="
+		},
+		"validate.io-integer": {
+			"version": "1.0.5",
+			"resolved": "https://registry.npmjs.org/validate.io-integer/-/validate.io-integer-1.0.5.tgz",
+			"integrity": "sha1-FoSWSAuVviJH7EQ/IjPeT4mHgGg=",
+			"requires": {
+				"validate.io-number": "^1.0.3"
+			}
+		},
+		"validate.io-integer-primitive": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/validate.io-integer-primitive/-/validate.io-integer-primitive-1.0.0.tgz",
+			"integrity": "sha1-qaoBA1X+hoHA/qbBp0rSQZyt3cY=",
+			"requires": {
+				"validate.io-number-primitive": "^1.0.0"
+			}
+		},
+		"validate.io-matrix-like": {
+			"version": "1.0.2",
+			"resolved": "https://registry.npmjs.org/validate.io-matrix-like/-/validate.io-matrix-like-1.0.2.tgz",
+			"integrity": "sha1-XsMqddCInaxzbepovdYUWxVe38M="
+		},
+		"validate.io-ndarray-like": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/validate.io-ndarray-like/-/validate.io-ndarray-like-1.0.0.tgz",
+			"integrity": "sha1-2KOw7RZbvx0vwNAHMnDPpVIpWRk="
+		},
+		"validate.io-nonnegative-integer": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/validate.io-nonnegative-integer/-/validate.io-nonnegative-integer-1.0.0.tgz",
+			"integrity": "sha1-gGkkOgjF+Y6VQTySnf17GPP28p8=",
+			"requires": {
+				"validate.io-integer": "^1.0.5"
+			}
+		},
+		"validate.io-number": {
+			"version": "1.0.3",
+			"resolved": "https://registry.npmjs.org/validate.io-number/-/validate.io-number-1.0.3.tgz",
+			"integrity": "sha1-9j/+2iSL8opnqNSODjtGGhZluvg="
+		},
+		"validate.io-number-primitive": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/validate.io-number-primitive/-/validate.io-number-primitive-1.0.0.tgz",
+			"integrity": "sha1-0uAfICmJNp3PEVVElWQgOv5YTlU="
+		},
+		"validate.io-positive-integer": {
+			"version": "1.0.0",
+			"resolved": "https://registry.npmjs.org/validate.io-positive-integer/-/validate.io-positive-integer-1.0.0.tgz",
+			"integrity": "sha1-ftLQO0wnVYzGagCqsPDpIYFKZYI=",
+			"requires": {
+				"validate.io-integer": "^1.0.5"
+			}
+		},
+		"validate.io-string-primitive": {
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/validate.io-string-primitive/-/validate.io-string-primitive-1.0.1.tgz",
+			"integrity": "sha1-uBNbn7E3K94C/dU60dDM1t55j+4="
+		},
+		"which": {
+			"version": "2.0.2",
+			"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+			"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+			"requires": {
+				"isexe": "^2.0.0"
+			}
+		},
 		"wkt-parser": {
 			"version": "1.2.4",
 			"resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.2.4.tgz",
diff --git a/package.json b/package.json
index 110c01b..8d68456 100644
--- a/package.json
+++ b/package.json
@@ -31,7 +31,14 @@
 	],
 	"homepage": "https://github.com/sbrl/terrain50-cli#readme",
 	"dependencies": {
+		"@iarna/toml": "^2.2.3",
 		"applause-cli": "^1.2.0",
-		"terrain50": "^1.0.0"
+		"chroma-js": "^2.1.0",
+		"image-encode": "^1.3.0",
+		"nexline": "^1.2.0",
+		"terrain50": "^1.3.0"
+	},
+	"bin": {
+		"terrain50": "./src/index.mjs"
 	}
 }
diff --git a/src/Bootstrap/cli.mjs b/src/Bootstrap/cli.mjs
new file mode 100644
index 0000000..7691e35
--- /dev/null
+++ b/src/Bootstrap/cli.mjs
@@ -0,0 +1,109 @@
+"use strict";
+
+import fs from 'fs';
+import path from 'path';
+
+import TOML from '@iarna/toml';
+
+import CliParser from 'applause-cli';
+
+import a from '../Helpers/Ansi.mjs';
+import settings from './settings.mjs';
+
+
+const __dirname = import.meta.url.slice(7, import.meta.url.lastIndexOf("/"));
+
+const subcommand_directory = "../Subcommands";
+
+async function get_actions() {
+	let dirs = await fs.promises.readdir(
+		path.resolve(
+			__dirname,
+			subcommand_directory
+		)
+	);
+	return dirs.map((dir) => {
+		let index_file = path.resolve(__dirname, `${subcommand_directory}/${dir}/`, "index.mjs");
+		if(!fs.existsSync(index_file))
+			return null;
+		
+		return path.basename(path.dirname(index_file));
+	}).filter(x => x !== null);
+}
+
+async function get_actions_metadata() {
+	let result = {};
+	for (let action of await get_actions()) {
+		let filepath = path.resolve(
+			__dirname,
+			`${subcommand_directory}/${action}/`,
+			`meta.toml`
+		);
+		if(!fs.existsSync(filepath)) {
+			result[action] = {
+				description: `${a.locol}${a.italics}(No description found)${a.reset}`,
+				arguments: []
+			};
+			continue;
+		}
+		
+		result[action] = TOML.parse(await fs.promises.readFile(filepath));
+	}
+	return result;
+}
+
+export default async function() {
+	let cli = new CliParser(path.resolve(__dirname, "../../package.json"));
+	
+	// Disable ansi escape codes if requested
+	if(!settings.output.ansi_colour) {
+	    a.enabled = false;
+	    a.escape_codes();
+	}
+	
+	let actions_meta = await get_actions_metadata();
+	for(let action in actions_meta) {
+		let subcommand = cli.subcommand(action, actions_meta[action].description);
+		if(!(actions_meta[action].arguments instanceof Array))
+			continue;
+		for(let argument of actions_meta[action].arguments) {
+			subcommand.argument(
+				argument.name,
+				argument.description,
+				argument.default_value,
+				argument.type
+			);
+		}
+	}
+	
+	// 2: CLI Argument Parsing
+	
+	settings.cli = cli.parse(process.argv.slice(2));
+	
+	let action = cli.current_subcommand;
+	
+	if(action == null) {
+		console.error(`${a.hicol}${a.fred}Error: No subcommand specified (try --help for usage information).${a.reset}`);
+		return;
+	}
+	
+	// 3: Environment Variable Parsing
+	
+	// process.env.XYZ
+	
+	
+	// 4: Run
+	console.error(`${a.fgreen}***** ${a.hicol}${action}${a.reset}${a.fgreen} *****${a.reset}`);
+	
+	try {
+		await (await import(`${subcommand_directory}/${action}/index.mjs`)).default(settings);
+	}
+	catch(error) {
+		console.error(`\n\n`);
+		throw error;
+	}
+	
+	// 5: Cleanup
+	
+	
+}
diff --git a/src/Bootstrap/settings.mjs b/src/Bootstrap/settings.mjs
new file mode 100644
index 0000000..954f80a
--- /dev/null
+++ b/src/Bootstrap/settings.mjs
@@ -0,0 +1,21 @@
+"use strict";
+
+import fs from 'fs';
+import path from 'path';
+
+const __dirname = import.meta.url.slice(7, import.meta.url.lastIndexOf("/"));
+
+// Default settings object
+// This is a (greatly) simplified stand-in for the much more advanced settings parsing system we have going on in our maina PhD-Code codebase.
+export default {
+	cli: {
+		program_name: "terrain50",
+		description: "manipulates ordnance survey digital elevation model files"
+	},
+	output: {
+		ansi_colour: true
+	},
+	package: JSON.parse(fs.readFileSync(
+		path.resolve(__dirname, "../../package.json")
+	))
+};
diff --git a/src/Helpers/Ansi.mjs b/src/Helpers/Ansi.mjs
new file mode 100644
index 0000000..ee0ab5d
--- /dev/null
+++ b/src/Helpers/Ansi.mjs
@@ -0,0 +1,82 @@
+"use strict";
+
+/**
+ * Generates various VT100 ANSI escape sequences.
+ * Ported from C#.
+ * @licence MPL-2.0 <https://www.mozilla.org/en-US/MPL/2.0/>
+ * @source https://gist.github.com/a4edd3204a03f4eedb79785751efb0f3#file-ansi-cs
+ * @author Starbeamrainbowlabs
+ * GitHub: @sbrl | Twitter: @SBRLabs | Reddit: u/Starbeamrainbowlabs
+ ***** Changelog *****
+ * 27th March 2019:
+ *  - Initial public release
+ * 9th March 2020:
+ * 	- Add Italics (\u001b[3m])
+ * 	- Export a new instance of it by default (makes it so that there's 1 global instance)
+ */
+class Ansi {
+	constructor() {
+		/**
+		 * Whether we should *actually* emit ANSI escape codes or not.
+		 * Useful when we want to output to a log file, for example
+		 * @type {Boolean}
+		 */
+		this.enabled = true;
+        
+		this.escape_codes();
+	}
+	
+	escape_codes() {
+		// Solution on how to output ANSI escape codes in C# from here:
+		// https://www.jerriepelser.com/blog/using-ansi-color-codes-in-net-console-apps
+		this.reset = this.enabled ? "\u001b[0m" : "";
+		this.hicol = this.enabled ? "\u001b[1m" : "";
+		this.locol = this.enabled ? "\u001b[2m" : "";
+		this.italics = this.enabled ? "\u001b[3m" : "";
+		this.underline = this.enabled ? "\u001b[4m" : "";
+		this.inverse = this.enabled ? "\u001b[7m" : "";
+		this.fblack = this.enabled ? "\u001b[30m" : "";
+		this.fred = this.enabled ? "\u001b[31m" : "";
+		this.fgreen = this.enabled ? "\u001b[32m" : "";
+		this.fyellow = this.enabled ? "\u001b[33m" : "";
+		this.fblue = this.enabled ? "\u001b[34m" : "";
+		this.fmagenta = this.enabled ? "\u001b[35m" : "";
+		this.fcyan = this.enabled ? "\u001b[36m" : "";
+		this.fwhite = this.enabled ? "\u001b[37m" : "";
+		this.bblack = this.enabled ? "\u001b[40m" : "";
+		this.bred = this.enabled ? "\u001b[41m" : "";
+		this.bgreen = this.enabled ? "\u001b[42m" : "";
+		this.byellow = this.enabled ? "\u001b[43m" : "";
+		this.bblue = this.enabled ? "\u001b[44m" : "";
+		this.bmagenta = this.enabled ? "\u001b[45m" : "";
+		this.bcyan = this.enabled ? "\u001b[46m" : "";
+		this.bwhite = this.enabled ? "\u001b[47m" : "";
+	}
+	
+	// Thanks to http://ascii-table.com/ansi-escape-sequences.php for the following ANSI escape sequences
+	up(lines = 1) {
+		return this.enabled ? `\u001b[${lines}A` : "";
+	}
+	down(lines = 1) {
+		return this.enabled ? `\u001b[${lines}B` : "";
+	}
+	right(lines = 1) {
+		return this.enabled ? `\u001b[${lines}C` : "";
+	}
+	left(lines = 1) {
+		return this.enabled ? `\u001b[${lines}D` : "";
+	}
+	
+	jump_to(x, y) {
+		return this.enabled ? `\u001b[${y};${x}H` : "";
+	}
+	
+	cursor_pos_save() {
+		return this.enabled ? `\u001b[s` : "";
+	}
+	cursor_pos_restore() {
+		return this.enabled ? `\u001b[u` : "";
+	}
+}
+
+export default (new Ansi());
diff --git a/src/Helpers/Log.mjs b/src/Helpers/Log.mjs
new file mode 100644
index 0000000..c88342f
--- /dev/null
+++ b/src/Helpers/Log.mjs
@@ -0,0 +1,82 @@
+"use strict";
+
+import a from './Ansi.mjs';
+
+const LOG_LEVELS = {
+	DEBUG: 0,
+	INFO: 1,
+	LOG: 2,
+	WARN: 4,
+	ERROR: 8,
+	NONE: 2048
+};
+
+/**
+ * Provides a simple logging utility. Depends on Ansi.mjs, and originated in my
+ * personal project woppleblox (which may or may not actually get finished).
+ */
+class Log {
+	constructor() {
+		this.start = new Date();
+		
+		this.level = LOG_LEVELS.DEBUG;
+		this.datetime_relative = false;
+	}
+	
+	
+	debug(...message) {
+		if(this.level > LOG_LEVELS.DEBUG) return;
+		this.__do_log("debug", ...message);
+	}
+	
+	info(...message) {
+		if(this.level > LOG_LEVELS.INFO) return;
+		this.__do_log("info", ...message);
+	}
+	
+	log(...message) {
+		if(this.level > LOG_LEVELS.LOG) return;
+		this.__do_log("log", ...message);
+	}
+	
+	warn(...message) {
+		if(this.level > LOG_LEVELS.WARN) return;
+		this.__do_log("warn", ...message);
+	}
+	
+	error(...message) {
+		if(this.level > LOG_LEVELS.ERROR) return;
+		this.__do_log("error", ...message);
+	}
+	
+	
+	__do_log(level, ...message) {
+		let part = `[ ${level} ]`;
+		switch(level) {
+			case "debug":
+				part = a.locol + part;
+				message.push(a.reset);
+				break;
+			case "warn":
+				part = a.fyellow + a.hicol + part;
+				message.push(a.reset);
+				break;
+			case "error":
+				part = a.fred + a.hicol + part;
+				message.push(a.reset);
+				break;
+		}
+		
+		let date = this.datetime_relative ? ((new Date() - this.start) / 1000).toFixed(3) : (new Date()).toISOString();
+		
+		message.unshift(`${part}`);
+		message.unshift(`${a.locol}[ ${date} ]${a.reset}`);
+		
+		console.error(...message);
+	}
+}
+
+// You won't normally need these
+export { LOG_LEVELS };
+
+export default new Log();
diff --git a/src/Subcommands/geojson/index.mjs b/src/Subcommands/geojson/index.mjs
new file mode 100644
index 0000000..5826fb0
--- /dev/null
+++ b/src/Subcommands/geojson/index.mjs
@@ -0,0 +1,59 @@
+"use strict";
+
+import fs from 'fs';
+
+import nexline from 'nexline';
+import chroma from 'chroma-js';
+
+import a from '../../Helpers/Ansi.mjs';
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+async function process_filename(result, filename) {
+	let next_item = Terrain50.Parse(await fs.promises.readFile(filename, "utf-8"))
+		.to_geojson_feature();
+	
+	next_item.properties.filename = filename;
+	let colour = chroma.random();
+	next_item.properties.fill = colour.hex();
+	next_item.properties.stroke = colour.darken().hex();
+	
+	result.features.push(next_item);
+}
+
+export default async function(settings) {
+	let result = {
+		type: "FeatureCollection",
+		features: []
+	};
+	
+	if(settings.cli.input instanceof Array) {
+		l.log(`Reading filenames from ${a.hicol}CLI arguments${a.reset}`);
+		
+		for(let filename of settings.cli.input) {
+			l.log(`Processing ${a.hicol}${filename}${a.reset} from CLI argument`);
+			await process_filename(result, filename);
+		}
+	}
+	else {
+		l.log(`Reading filenames from ${a.hicol}stdin${a.reset}`);
+		
+		let reader = nexline({
+			input: process.stdin
+		});
+		
+		while(true) {
+			let filename = await reader.next();
+			if(filename == null)
+				break;
+			if(filename.length == 0) continue;
+			
+			l.log(`Processing ${a.hicol}${filename}${a.reset} from stdin`);
+			
+			await process_filename(result, filename);
+		}
+	}
+	
+	l.log(`${a.hicol}${a.fgreen}Done${a.reset}`);
+	console.log(JSON.stringify(result));
+}
diff --git a/src/Subcommands/geojson/meta.toml b/src/Subcommands/geojson/meta.toml
new file mode 100644
index 0000000..34f6abb
--- /dev/null
+++ b/src/Subcommands/geojson/meta.toml
@@ -0,0 +1,7 @@
+description = "Convert 1 or more Terrain50 files to a GeoJSON bounding box"
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file(s) to operate on. May be specified multiple times."
+# default_value = ""
+type = "string_multi"
diff --git a/src/Subcommands/image/Terrain50Renderer.mjs b/src/Subcommands/image/Terrain50Renderer.mjs
new file mode 100644
index 0000000..5503225
--- /dev/null
+++ b/src/Subcommands/image/Terrain50Renderer.mjs
@@ -0,0 +1,110 @@
+"use strict";
+
+import chroma from 'chroma-js';
+import encode from 'image-encode';
+
+import l from '../../Helpers/Log.mjs';
+
+class Terrain50Renderer {
+	constructor(in_scale_factor) {
+		this.scale_factor = in_scale_factor;
+		this.colour_scale = chroma.scale([
+			"#333333",
+			// chroma("white").alpha(0),
+			// "green"
+			"#efefef",
+		]).mode('lrgb');
+		this.colour_nodata = chroma("#f97153").rgba();
+	}
+
+	/**
+	 * Actually does the rendering, returning a canvas with the image rendered 
+	 * onto it.
+	 * You probably want the .render() method, which returns a buffer 
+	 * containing a png-encoded image.
+	 * @param	{Terrain50}	terrain	The Terrain50 object instance to render.
+	 * @return	{ArrayBuffer}	A canvas with the image rendered on it.
+	 */
+	async do_render(terrain) {
+		let min = terrain.min_value, max = terrain.max_value;
+		let colour_domain = this.colour_scale.domain([
+			min, max
+		]);
+	
+		let width = Math.floor(terrain.meta.ncols / this.scale_factor),
+			height = Math.floor(terrain.meta.nrows / this.scale_factor);
+	
+		l.log(`[Terrain50Renderer] Colour domain: ${min} - ${max}`);
+		l.log(`[Terrain50Renderer] Dimensions: ${width}x${height}`);
+	
+		// Create the image
+		let pixels = new ArrayBuffer(width * height * 4);
+		let view8 = new Uint8ClampedArray(pixels),
+			view32 = new Uint32Array(pixels); // For image generation by us
+		// let view32 = new Uint32Array(img.bitmap.data);
+	
+		let count = 0;
+		for(let y = 0; y < height*this.scale_factor; y += this.scale_factor) {
+			for(let x = 0; x < width*this.scale_factor; x += this.scale_factor) {
+				// l.debug(`Processing (${x}, ${y})`)
+				
+				let a_y = Math.floor(y / this.scale_factor),
+					a_x = Math.floor(x / this.scale_factor);
+				
+				// chroma.js clamps automagically :D
+				let colour = this.colour_nodata;
+				if(typeof terrain.data[a_y] !== "undefined" &&
+					terrain.data[a_y][a_x] !== terrain.meta.NODATA_value) {
+					
+					colour = colour_domain(
+						terrain.data[a_y][a_x]
+					).rgba(); // 0: r, 1: g, 2: b, a: 3
+				}
+				
+				// colour = chroma("red").rgba();
+				colour[3] = Math.floor(colour[3] * 255); // Scale the alpha value from 0-1 to 0-255
+				
+				if(colour[3] < 1) console.log(`(${x}, ${y})`, colour);
+				
+				// img.bitmap.data[a_y*width + a_x + 0] = colour[0];
+				// img.bitmap.data[a_y*width + a_x + 1] = colour[1];
+				// img.bitmap.data[a_y*width + a_x + 2] = colour[2];
+				// img.bitmap.data[a_y*width + a_x + 3] = colour[3];
+				view32[count] = 
+					(colour[3] << 24) |	// a
+					(colour[2] << 16) |	// b
+					(colour[1] << 8) |	// g
+					colour[0];			// r
+				// img.bitmap.data[a_y*width + a_x] = 
+				// 	(colour[0] << 24) |	// r
+				// 	(colour[1] << 16) |	// g
+				// 	(colour[2] << 8) |	// b
+				// 	colour[3];			// a
+	
+				// console.log(`(${x}, ${y}) -> (${a_x}, ${a_y}) @  SF ${this.scale_factor}`, terrain.data[y][x], colour);
+				count++;
+			}
+		}
+		
+		l.log(`Written to ${count} pixels (${view32.length} present, ${((count/(width*height))*100).toFixed(2)}%)`);
+		console.log(view8);
+		return view8;
+	}
+
+	/**
+	 * Renders the given Terrain50 object to an image.
+	 * Returns a buffer containing a PNG-encoded image, which is ready to be
+	 * written to disk for example.
+	 * @param	{Terrain50}	terrain	The terrain object to render.
+	 * @return	{Buffer}	The terrain object as a png, represented as a buffer.
+	 */
+	async render(terrain) {
+		let width = Math.floor(terrain.meta.ncols / this.scale_factor),
+			height = Math.floor(terrain.meta.nrows / this.scale_factor);
+		
+		let result = await this.do_render(terrain);
+		return Buffer.from(encode(result, [ width, height ], "png"));
+	}
+}
+
+export default Terrain50Renderer;
diff --git a/src/Subcommands/image/index.mjs b/src/Subcommands/image/index.mjs
new file mode 100644
index 0000000..cbc4119
--- /dev/null
+++ b/src/Subcommands/image/index.mjs
@@ -0,0 +1,36 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import a from '../../Helpers/Ansi.mjs';
+
+import Terrain50 from 'terrain50';
+import Terrain50Renderer from './Terrain50Renderer.mjs';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/file.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output file specified (try --filename-output path/to/image.png)");
+		process.exit(1);
+	}
+	
+	let terrain50 = Terrain50.Parse(
+		await fs.promises.readFile(settings.cli.input, "utf-8")
+	);
+	
+	let renderer = new Terrain50Renderer(settings.scale_factor | 1);
+	let png_buffer = await renderer.render(terrain50);
+	
+	if(!(png_buffer instanceof Buffer))
+		throw new Error(`Error: Renderer did not return Buffer (found unexpected ${png_buffer} instead)`);
+	
+	await fs.promises.writeFile(
+		settings.cli.output,
+		png_buffer
+	);
+	l.log(`Written to ${a.hicol}${settings.cli.output}${a.reset}`);
+}
diff --git a/src/Subcommands/image/meta.toml b/src/Subcommands/image/meta.toml
new file mode 100644
index 0000000..66669aa
--- /dev/null
+++ b/src/Subcommands/image/meta.toml
@@ -0,0 +1,13 @@
+description = "Convert a Terrain50 file into an image"
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The output file to write to"
+# default_value = ""
+type = "string"
diff --git a/src/Subcommands/merge/index.mjs b/src/Subcommands/merge/index.mjs
new file mode 100644
index 0000000..7ea5522
--- /dev/null
+++ b/src/Subcommands/merge/index.mjs
@@ -0,0 +1,74 @@
+#!/usr/local/bin/node --experimental-modules
+"use strict";
+
+import path from 'path';
+import fs from 'fs';
+
+import a from '../../Helpers/Ansi.mjs';
+import l from '../../Helpers/Log.mjs';
+
+import Terrain50 from './Terrain50.mjs';
+
+// const __dirname = import.meta.url.slice(7, import.meta.url.lastIndexOf("/"));
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		console.error(`${a.fred}${a.hicol}Error: No directory specified (try --filename path/to/directory)${a.reset}`);
+		process.exit(2);
+	}
+	if(typeof settings.cli.output !== "string") {
+		console.error(`${a.fred}${a.hicol}Error: No output file specified (try --filename-output path/to/filename.asc)${a.reset}`);
+		process.exit(2);
+	}
+	
+	let files = await fs.promises.readdir(settings.cli.input);
+	
+	l.log(`Parsing input files`);
+	
+	let input_files = [];
+	let i = 0;
+	for(let file of files) {
+		process.stdout.write(`[${i+1} / ${files.length}] ${file}\r`);
+		let next = Terrain50.Parse(await fs.promises.readFile(
+			path.resolve(settings.cli.input, file),
+			{ encoding: "utf-8" }
+		));
+		input_files.push(next);
+		i++;
+			
+	}
+	process.stdout.write("\n");
+	l.log(`done!`);
+	
+	let big_tile = Terrain50.Merge(...input_files);
+	
+	l.log(`Validating merge...`);
+	l.log(`Merge is ${big_tile.meta.ncols}x${big_tile.meta.nrows} @ (${big_tile.meta.xllcorner}, ${big_tile.meta.yllcorner}) on the OS National Grid`)
+	let validation_errors = big_tile.validate();
+	if(validation_errors.length > 0) {
+		l.warn(`Warning: ${validation_errors.length} validation issues detected with merge.`);
+		l.warn(`Details:`);
+		for(let error of validation_errors) {
+			switch(error.level) {
+				case "warning":
+					l.warn(error.toString());
+					break;
+				case "error":
+					l.error(error.toString());
+					break;
+				default:
+					l.log(error.toString());
+					break;
+			}
+		}
+	}
+	else {
+		l.log(`${a.fgreen}Validation complete, no issues detected${a.reset}`);
+	}
+	
+	l.log("Serialising to file");
+	let stream_out = fs.createWriteStream(settings.cli.output);
+	await big_tile.serialise(stream_out);
+	stream_out.end();
+	l.log("done, merge complete");
+}
diff --git a/src/Subcommands/merge/meta.toml b/src/Subcommands/merge/meta.toml
new file mode 100644
index 0000000..0063c1e
--- /dev/null
+++ b/src/Subcommands/merge/meta.toml
@@ -0,0 +1,13 @@
+description = "Merge multiple Terrain50 instances into a single file"
+
+[[arguments]]
+name = "input"
+description = "The input directory to read Terrain50 files from"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The output file to write the merged Terrain50 instances to"
+# default_value = ""
+type = "string"
diff --git a/src/Subcommands/replace/index.mjs b/src/Subcommands/replace/index.mjs
new file mode 100644
index 0000000..a04d43b
--- /dev/null
+++ b/src/Subcommands/replace/index.mjs
@@ -0,0 +1,36 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/heightmap.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output file specified (try --filename path/to/output.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.find !== "number") {
+		l.error("Error: No target value to find (try --find INTEGER_HERE)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.replace !== "number") {
+		l.error("Error: No target value to replace (try --find INTEGER_HERE)");
+		process.exit(1);
+	}
+	
+	l.log(`Reading input files`);
+	let heightmap = Terrain50.Parse(await fs.promises.readFile(settings.cli.input, "utf-8"));
+	
+	l.log(`Replacing ${settings.cli.find} with ${settings.cli.replace}`);
+	heightmap.replace(settings.cli.find, settings.cli.replace);
+	l.log(`done`);
+	
+	l.log(`Writing result to disk`);
+	await heightmap.serialise(fs.createWriteStream(settings.cli.output));
+	l.log(`Trimming complete!`);
+}
diff --git a/src/Subcommands/replace/meta.toml b/src/Subcommands/replace/meta.toml
new file mode 100644
index 0000000..9afb56e
--- /dev/null
+++ b/src/Subcommands/replace/meta.toml
@@ -0,0 +1,25 @@
+description = "Find and replace values in an ascii Terrain50 file."
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file to operate on"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The filepath to write the output to"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "find"
+description = "The target value to find"
+# default_value = ""
+type = "integer"
+
+[[arguments]]
+name = "replace"
+description = "The value to replace values that match the target value"
+# default_value = ""
+type = "integer"
diff --git a/src/Subcommands/round/index.mjs b/src/Subcommands/round/index.mjs
new file mode 100644
index 0000000..a347bff
--- /dev/null
+++ b/src/Subcommands/round/index.mjs
@@ -0,0 +1,28 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/heightmap.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output file specified (try --filename path/to/output.asc)");
+		process.exit(1);
+	}
+	
+	l.log(`Reading input files`);
+	let heightmap = Terrain50.Parse(await fs.promises.readFile(settings.cli.input, "utf-8"));
+	
+	l.log(`Rounding values`);
+	heightmap.round();
+	l.log(`done`);
+	
+	l.log(`Writing result to disk`);
+	await heightmap.serialise(fs.createWriteStream(settings.cli.output));
+	l.log(`Trimming complete!`);
+}
diff --git a/src/Subcommands/round/meta.toml b/src/Subcommands/round/meta.toml
new file mode 100644
index 0000000..0a7e4f5
--- /dev/null
+++ b/src/Subcommands/round/meta.toml
@@ -0,0 +1,13 @@
+description = "Round values to the nearest integer in an ascii Terrain50 file."
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file to operate on"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The filepath to write the output to"
+# default_value = ""
+type = "string"
diff --git a/src/Subcommands/scale/index.mjs b/src/Subcommands/scale/index.mjs
new file mode 100644
index 0000000..fb0a5ab
--- /dev/null
+++ b/src/Subcommands/scale/index.mjs
@@ -0,0 +1,34 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/heightmap.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output file specified (try --filename path/to/output.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.scale_factor !== "number") {
+		l.error("Error: No scale factor specified (try --scale-factor NUMBER_HERE)");
+		process.exit(1);
+	}
+	
+	l.log(`Reading input files`);
+	let heightmap = Terrain50.Parse(await fs.promises.readFile(settings.cli.input, "utf-8"));
+	
+	
+	l.log(`Applying scale factor`);
+	heightmap.scale(settings.cli.scale_factor);
+	
+	l.log(`Scaled by scale factor ${settings.cli.scale_factor}`);
+	
+	l.log(`Writing result to disk`);
+	await heightmap.serialise(fs.createWriteStream(settings.cli.output));
+	l.log(`Scaling complete!`);
+}
diff --git a/src/Subcommands/scale/meta.toml b/src/Subcommands/scale/meta.toml
new file mode 100644
index 0000000..c2c01d8
--- /dev/null
+++ b/src/Subcommands/scale/meta.toml
@@ -0,0 +1,19 @@
+description = "Scales the size of a Terrain50 file by a given scale factor."
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The output filepath to write to"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "scale-factor"
+description = "The scale factor to apply."
+# default_value = ""
+type = "float"
diff --git a/src/Subcommands/shift/index.mjs b/src/Subcommands/shift/index.mjs
new file mode 100644
index 0000000..24c00d0
--- /dev/null
+++ b/src/Subcommands/shift/index.mjs
@@ -0,0 +1,34 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/heightmap.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output file specified (try --filename path/to/output.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.min_value !== "number") {
+		l.error("Error: No new minimum value specified (try --min-value NUMBER_HERE)");
+		process.exit(1);
+	}
+	
+	l.log(`Reading input files`);
+	let heightmap = Terrain50.Parse(await fs.promises.readFile(settings.cli.input, "utf-8"));
+	
+	
+	l.log(`Shifting values`);
+	let shift_amount = heightmap.shift(settings.cli.min_value);
+	
+	l.log(`Shifted values by ${shift_amount}`);
+	
+	l.log(`Writing result to disk`);
+	await heightmap.serialise(fs.createWriteStream(settings.cli.output));
+	l.log(`Shifting complete!`);
+}
diff --git a/src/Subcommands/shift/meta.toml b/src/Subcommands/shift/meta.toml
new file mode 100644
index 0000000..5d94ad5
--- /dev/null
+++ b/src/Subcommands/shift/meta.toml
@@ -0,0 +1,19 @@
+description = "Shift the values in a Terrain50 file such that the minimum value is a given number"
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "output"
+description = "The output filepath to write to"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "min-value"
+description = "The new minimum value to shift to."
+# default_value = ""
+type = "integer"
diff --git a/src/Subcommands/trim/index.mjs b/src/Subcommands/trim/index.mjs
new file mode 100644
index 0000000..b9be089
--- /dev/null
+++ b/src/Subcommands/trim/index.mjs
@@ -0,0 +1,39 @@
+"use strict";
+
+import fs from 'fs';
+
+import l from '../../Helpers/Log.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(settings) {
+	if(typeof settings.cli.input !== "string") {
+		l.error("Error: No input file specified (try --filename path/to/heightmap.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.reference !== "string") {
+		l.error("Error: No reference file specified (try --filename-reference path/to/another.asc)");
+		process.exit(1);
+	}
+	if(typeof settings.cli.output !== "string") {
+		l.error("Error: No output filename specified (try --filename-output path/to/file.asc)");
+		process.exit(1);
+	}
+	
+	l.log(`Reading input files`);
+	let heightmap = Terrain50.Parse(await fs.promises.readFile(settings.cli.input, "utf-8")),
+		reference = Terrain50.Parse(await fs.promises.readFile(settings.cli.reference, "utf-8"));
+	
+	l.log(`Trimming heightmap according to reference`);
+	heightmap.trim(reference.meta);
+	
+	if(typeof settings.cli.replace !== "undefined") {
+		let [old_value, new_value] = settings.cli.replace.split(/,/g).map((x) => parseInt(x, 10));
+		l.log(`Replacing ${old_value} with ${new_value}`);
+		heightmap.replace(old_value, new_value);
+		l.log(`done`);
+	}
+	
+	l.log(`Writing result to disk`);
+	await heightmap.serialise(fs.createWriteStream(settings.cli.output));
+	l.log(`Trimming complete!`);
+}
diff --git a/src/Subcommands/trim/meta.toml b/src/Subcommands/trim/meta.toml
new file mode 100644
index 0000000..f8310ad
--- /dev/null
+++ b/src/Subcommands/trim/meta.toml
@@ -0,0 +1,23 @@
+description = "Trim a Terrain50 file down to match a 2nd reference Terrain50 file"
+
+[[arguments]]
+name = "input"
+description = "The input Terrain50 file"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "reference"
+description = "The reference Terrain50 to trim the input to"
+
+[[arguments]]
+name = "output"
+description = "The output filepath to write to"
+# default_value = ""
+type = "string"
+
+[[arguments]]
+name = "replace"
+description = "Replace 1 value with another while trimming. Argument value format is \"old_value,new_value\"."
+# default_value = ""
+type = "string" # We could really have used this being a JS file instead - and we could define a function here instead
diff --git a/src/Subcommands/validator/index.mjs b/src/Subcommands/validator/index.mjs
new file mode 100644
index 0000000..6ce099a
--- /dev/null
+++ b/src/Subcommands/validator/index.mjs
@@ -0,0 +1,34 @@
+"use strict";
+
+import fs from 'fs';
+
+import a from '../../Helpers/Ansi.mjs';
+import Terrain50 from 'terrain50';
+
+export default async function(_settings) {
+	// Read stdin into a string - ref https://stackoverflow.com/a/56012724/1460422
+	let source = fs.readFileSync(0, 'utf-8');
+	
+	let errors = Terrain50.Validate(source);
+	
+	if(errors.length > 0) {
+		for(let error of errors) {
+			switch(error.level) {
+				case "warning":
+					console.log(`${a.fyellow}${a.hicol}${error}${a.reset}`);
+					break;
+				case "error":
+					console.log(`${a.fred}${a.hicol}${error}${a.reset}`);
+					break;
+				default:
+					console.log(error);
+					break;
+			}
+		}
+		process.exit(1);
+	}
+	else {
+		console.log(`${a.fgreen}Validation completed successfully - no issues detected :D${a.reset}`);
+	}
+	
+}
diff --git a/src/Subcommands/validator/meta.toml b/src/Subcommands/validator/meta.toml
new file mode 100644
index 0000000..6260cc3
--- /dev/null
+++ b/src/Subcommands/validator/meta.toml
@@ -0,0 +1 @@
+description = "Read the Terrain50 file on stdin, validate it, and display any errors found"
diff --git a/src/index.mjs b/src/index.mjs
new file mode 100755
index 0000000..864e95f
--- /dev/null
+++ b/src/index.mjs
@@ -0,0 +1,9 @@
+#!/usr/bin/env node
+
+import cli from './Bootstrap/cli.mjs';
+
+(async () => {
+    "use strict";
+    
+    await cli();
+})();
