Merge pull request #36 from jaller94/decaffeinate

Decaffeinate Mapscii
This commit is contained in:
Christian Paul 2018-10-16 23:46:08 -07:00 committed by GitHub
commit 7a313c8a7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 2861 additions and 1377 deletions

29
.eslintrc.js Normal file
View File

@ -0,0 +1,29 @@
module.exports = {
"env": {
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"rules": {
"indent": [
"error",
2,
{
"SwitchCase": 1
}
],
"linebreak-style": [
"error",
"unix"
],
"no-console": 0,
"quotes": [
"error",
"single"
],
"semi": [
"error",
"always"
]
}
};

View File

@ -22,7 +22,7 @@ If you're on Windows, use the open source telnet client [PuTTY](https://www.chia
* Work offline and discover local [VectorTile](https://github.com/mapbox/vector-tile-spec)/[MBTiles](https://github.com/mapbox/mbtiles-spec)
* Compatible with most Linux and OSX terminals
* Highly optimizied algorithms for a smooth experience
* 100% pure Coffee-/JavaScript! :sunglasses:
* 100% pure JavaScript! :sunglasses:
## How to install it locally

View File

@ -7,9 +7,8 @@
TODO: params parsing and so on
#*/
require('coffee-script/register');
'use strict';
const Mapscii = require('./src/Mapscii');
mapscii = new Mapscii();
const mapscii = new Mapscii();
mapscii.init();

1095
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,6 @@
"@mapbox/vector-tile": "^1.3.0",
"bluebird": "^3.5.1",
"bresenham": "0.0.4",
"coffee-script": "^1.12.7",
"earcut": "^2.1.2",
"keypress": "^0.2.1",
"node-fetch": "^1.7.3",
@ -43,5 +42,8 @@
"term-mouse": "^0.2.0",
"userhome": "^1.0.0",
"x256": "0.0.2"
},
"devDependencies": {
"eslint": "^4.13.1"
}
}

View File

@ -1,156 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Simple pixel to braille character mapper
Implementation inspired by node-drawille (https://github.com/madbence/node-drawille)
* added color support
* added text label support
* general optimizations
Will either be merged into node-drawille or become an own module at some point
###
stringWidth = require 'string-width'
config = require './config'
utils = require './utils'
module.exports = class BrailleBuffer
brailleMap: [[0x1, 0x8],[0x2, 0x10],[0x4, 0x20],[0x40, 0x80]]
asciiMap:
# "": [2+32, 4+64]
# "¯": [1+16]
"": [1+2+16+32]
"": [4+8+64+128]
"": [2+4+32+64]
"": [1+2+4+8]
"": [16+32+64+128]
# "": [1+4+32+128, 2+8+16+64]
"": [255]
pixelBuffer: null
charBuffer: null
foregroundBuffer: null
backgroundBuffer: null
asciiToBraille: []
globalBackground: null
termReset: "\x1B[39;49m"
constructor: (@width, @height) ->
size = @width*@height/8
@pixelBuffer = new Buffer size
@foregroundBuffer = new Buffer size
@backgroundBuffer = new Buffer size
@_mapBraille()
@clear()
clear: ->
@pixelBuffer.fill 0
@charBuffer = []
@foregroundBuffer.fill 0
@backgroundBuffer.fill 0
setGlobalBackground: (@globalBackground) ->
setBackground: (x, y, color) ->
return unless 0 <= x < @width and 0 <= y < @height
idx = @_project x, y
@backgroundBuffer[idx] = color
setPixel: (x, y, color) ->
@_locate x, y, (idx, mask) =>
@pixelBuffer[idx] |= mask
@foregroundBuffer[idx] = color
unsetPixel: (x, y) ->
@_locate x, y, (idx, mask) =>
@pixelBuffer[idx] &= ~mask
_project: (x, y) ->
(x>>1) + (@width>>1)*(y>>2)
_locate: (x, y, cb) ->
return unless 0 <= x < @width and 0 <= y < @height
idx = @_project x, y
mask = @brailleMap[y&3][x&1]
cb idx, mask
_mapBraille: ->
@asciiToBraille = [" "]
masks = []
for char, bits of @asciiMap
continue unless bits instanceof Array
masks.push mask: mask, char: char for mask in bits
for i in [1..255]
braille = (i&7) + ((i&56)<<1) + ((i&64)>>3) + (i&128)
@asciiToBraille[i] = masks.reduce(((best, mask) ->
covered = utils.population(mask.mask&braille)
if not best or best.covered < covered
char: mask.char, covered: covered
else
best
), undefined).char
_termColor: (foreground, background) ->
background = background or @globalBackground
if foreground and background
"\x1B[38;5;#{foreground};48;5;#{background}m"
else if foreground
"\x1B[49;38;5;#{foreground}m"
else if background
"\x1B[39;48;5;#{background}m"
else
@termReset
frame: ->
output = []
currentColor = null
skip = 0
for y in [0...@height/4]
skip = 0
for x in [0...@width/2]
idx = y*@width/2 + x
if idx and not x
output.push config.delimeter
if currentColor isnt colorCode = @_termColor @foregroundBuffer[idx], @backgroundBuffer[idx]
output.push currentColor = colorCode
output.push if char = @charBuffer[idx]
skip += stringWidth(char)-1
if skip+x >= @width/2
''
else
char
else
if not skip
if config.useBraille
String.fromCharCode 0x2800+@pixelBuffer[idx]
else
@asciiToBraille[@pixelBuffer[idx]]
else
skip--
''
output.push @termReset+config.delimeter
output.join ''
setChar: (char, x, y, color) ->
return unless 0 <= x < @width and 0 <= y < @height
idx = @_project x, y
@charBuffer[idx] = char
@foregroundBuffer[idx] = color
writeText: (text, x, y, color, center = true) ->
x -= text.length/2+1 if center
@setChar text.charAt(i), x+i*2, y, color for i in [0...text.length]

211
src/BrailleBuffer.js Normal file
View File

@ -0,0 +1,211 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Simple pixel to braille character mapper
Implementation inspired by node-drawille (https://github.com/madbence/node-drawille)
* added color support
* added text label support
* general optimizations
Will either be merged into node-drawille or become an own module at some point
*/
'use strict';
const stringWidth = require('string-width');
const config = require('./config');
const utils = require('./utils');
const asciiMap = {
// '▬': [2+32, 4+64],
// '¯': [1+16],
'▀': [1+2+16+32],
'▄': [4+8+64+128],
'■': [2+4+32+64],
'▌': [1+2+4+8],
'▐': [16+32+64+128],
// '▓': [1+4+32+128, 2+8+16+64],
'█': [255],
};
const termReset = '\x1B[39;49m';
class BrailleBuffer {
constructor(width, height) {
this.brailleMap = [[0x1, 0x8],[0x2, 0x10],[0x4, 0x20],[0x40, 0x80]];
this.pixelBuffer = null;
this.charBuffer = null;
this.foregroundBuffer = null;
this.backgroundBuffer = null;
this.asciiToBraille = [];
this.globalBackground = null;
this.width = width;
this.height = height;
const size = width*height/8;
this.pixelBuffer = new Buffer(size);
this.foregroundBuffer = new Buffer(size);
this.backgroundBuffer = new Buffer(size);
this._mapBraille();
this.clear();
}
clear() {
this.pixelBuffer.fill(0);
this.charBuffer = [];
this.foregroundBuffer.fill(0);
this.backgroundBuffer.fill(0);
}
setGlobalBackground(background) {
this.globalBackground = background;
}
setBackground(x, y, color) {
if (0 <= x && x < this.width && 0 <= y && y < this.height) {
const idx = this._project(x, y);
this.backgroundBuffer[idx] = color;
}
}
setPixel(x, y, color) {
this._locate(x, y, (idx, mask) => {
this.pixelBuffer[idx] |= mask;
this.foregroundBuffer[idx] = color;
});
}
unsetPixel(x, y) {
this._locate(x, y, (idx, mask) => {
this.pixelBuffer[idx] &= ~mask;
});
}
_project(x, y) {
return (x>>1) + (this.width>>1)*(y>>2);
}
_locate(x, y, cb) {
if (!((0 <= x && x < this.width) && (0 <= y && y < this.height))) {
return;
}
const idx = this._project(x, y);
const mask = this.brailleMap[y & 3][x & 1];
return cb(idx, mask);
}
_mapBraille() {
this.asciiToBraille = [' '];
const masks = [];
for (const char in asciiMap) {
const bits = asciiMap[char];
if (!(bits instanceof Array)) continue;
for (const mask of bits) {
masks.push({
mask: mask,
char: char,
});
}
}
//TODO Optimize this part
var i, k;
const results = [];
for (i = k = 1; k <= 255; i = ++k) {
const braille = (i & 7) + ((i & 56) << 1) + ((i & 64) >> 3) + (i & 128);
results.push(this.asciiToBraille[i] = masks.reduce((function(best, mask) {
const covered = utils.population(mask.mask & braille);
if (!best || best.covered < covered) {
return {
char: mask.char,
covered: covered,
};
} else {
return best;
}
}), void 0).char);
}
return results;
}
_termColor(foreground, background) {
background |= this.globalBackground;
if (foreground && background) {
return `\x1B[38;5;${foreground};48;5;${background}m`;
} else if (foreground) {
return `\x1B[49;38;5;${foreground}m`;
} else if (background) {
return `\x1B[39;48;5;${background}m`;
} else {
return termReset;
}
}
frame() {
const output = [];
let currentColor = null;
let skip = 0;
for (let y = 0; y < this.height/4; y++) {
skip = 0;
for (let x = 0; x < this.width/2; x++) {
const idx = y*this.width/2 + x;
if (idx && !x) {
output.push(config.delimeter);
}
const colorCode = this._termColor(this.foregroundBuffer[idx], this.backgroundBuffer[idx]);
if (currentColor !== colorCode) {
output.push(currentColor = colorCode);
}
const char = this.charBuffer[idx];
if (char) {
skip += stringWidth(char)-1;
if (skip+x < this.width/2) {
output.push(char);
}
} else {
if (!skip) {
if (config.useBraille) {
output.push(String.fromCharCode(0x2800+this.pixelBuffer[idx]));
} else {
output.push(this.asciiToBraille[this.pixelBuffer[idx]]);
}
} else {
skip--;
}
}
}
}
output.push(termReset+config.delimeter);
return output.join('');
}
setChar(char, x, y, color) {
if (0 <= x && x < this.width && 0 <= y && y < this.height) {
const idx = this._project(x, y);
this.charBuffer[idx] = char;
this.foregroundBuffer[idx] = color;
}
}
writeText(text, x, y, color, center = true) {
if (center) {
x -= text.length/2+1;
}
for (let i = 0; i < text.length; i++) {
this.setChar(text.charAt(i), x+i*2, y, color);
}
}
}
module.exports = BrailleBuffer;

View File

@ -1,161 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Canvas-like painting abstraction for BrailleBuffer
Implementation inspired by node-drawille-canvas (https://github.com/madbence/node-drawille-canvas)
* added support for filled polygons
* improved text rendering
Will most likely be turned into a stand alone module at some point
###
bresenham = require 'bresenham'
simplify = require 'simplify-js'
earcut = require 'earcut'
BrailleBuffer = require './BrailleBuffer'
utils = require './utils'
module.exports = class Canvas
stack: []
constructor: (@width, @height) ->
@buffer = new BrailleBuffer @width, @height
frame: ->
@buffer.frame()
clear: ->
@buffer.clear()
text: (text, x, y, color, center = false) ->
@buffer.writeText text, x, y, color, center
line: (from, to, color, width = 1) ->
@_line from.x, from.y, to.x, to.y, color, width
polyline: (points, color, width = 1) ->
for i in [1...points.length]
@_line points[i-1].x, points[i-1].y, points[i].x, points[i].y, width, color
setBackground: (color) ->
@buffer.setGlobalBackground color
background: (x, y, color) ->
@buffer.setBackground x, y, color
polygon: (rings, color) ->
vertices = []
holes = []
for ring in rings
if vertices.length
continue if ring.length < 3
holes.push vertices.length/2
else
return false if ring.length < 3
for point in ring
vertices.push point.x
vertices.push point.y
try
triangles = earcut vertices, holes
catch e
return false
for i in [0...triangles.length] by 3
pa = @_polygonExtract vertices, triangles[i]
pb = @_polygonExtract vertices, triangles[i+1]
pc = @_polygonExtract vertices, triangles[i+2]
@_filledTriangle pa, pb, pc, color
true
_polygonExtract: (vertices, pointId) ->
[vertices[pointId*2], vertices[pointId*2+1]]
# Inspired by Alois Zingl's "The Beauty of Bresenham's Algorithm"
# -> http://members.chello.at/~easyfilter/bresenham.html
_line: (x0, y0, x1, y1, width, color) ->
# Fall back to width-less bresenham algorithm if we dont have a width
unless width = Math.max 0, width-1
return bresenham x0, y0, x1, y1,
(x, y) => @buffer.setPixel x, y, color
dx = Math.abs x1-x0
sx = if x0 < x1 then 1 else -1
dy = Math.abs y1-y0
sy = if y0 < y1 then 1 else -1
err = dx-dy
ed = if dx+dy is 0 then 1 else Math.sqrt dx*dx+dy*dy
width = (width+1)/2
loop
@buffer.setPixel x0, y0, color
e2 = err
x2 = x0
if 2*e2 >= -dx
e2 += dy
y2 = y0
while e2 < ed*width && (y1 != y2 || dx > dy)
@buffer.setPixel x0, y2 += sy, color
e2 += dx
break if x0 is x1
e2 = err
err -= dy
x0 += sx
if 2*e2 <= dy
e2 = dx-e2
while e2 < ed*width && (x1 != x2 || dx < dy)
@buffer.setPixel x2 += sx, y0, color
e2 += dy
break if y0 is y1
err += dx
y0 += sy
_filledRectangle: (x, y, width, height, color) ->
pointA = [x, y]
pointB = [x+width, y]
pointC = [x, y+height]
pointD = [x+width, y+height]
@_filledTriangle pointA, pointB, pointC, color
@_filledTriangle pointC, pointB, pointD, color
_bresenham: (pointA, pointB) ->
bresenham pointA[0], pointA[1],
pointB[0], pointB[1]
# Draws a filled triangle
_filledTriangle: (pointA, pointB, pointC, color) ->
a = @_bresenham pointB, pointC
b = @_bresenham pointA, pointC
c = @_bresenham pointA, pointB
points = a.concat(b).concat(c)
.filter (point) => 0 <= point.y < @height
.sort (a, b) -> if a.y is b.y then a.x - b.x else a.y-b.y
for i in [0...points.length]
point = points[i]
next = points[i*1+1]
if point.y is next?.y
left = Math.max 0, point.x
right = Math.min @width-1, next.x
if left >= 0 and right <= @width
@buffer.setPixel x, point.y, color for x in [left..right]
else
@buffer.setPixel point.x, point.y, color
break unless next

202
src/Canvas.js Normal file
View File

@ -0,0 +1,202 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Canvas-like painting abstraction for BrailleBuffer
Implementation inspired by node-drawille-canvas (https://github.com/madbence/node-drawille-canvas)
* added support for filled polygons
* improved text rendering
Will most likely be turned into a stand alone module at some point
*/
'use strict';
const bresenham = require('bresenham');
const earcut = require('earcut');
const BrailleBuffer = require('./BrailleBuffer');
class Canvas {
constructor(width, height) {
this.width = width;
this.height = height;
this.buffer = new BrailleBuffer(width, height);
}
frame() {
return this.buffer.frame();
}
clear() {
this.buffer.clear();
}
text(text, x, y, color, center = false) {
this.buffer.writeText(text, x, y, color, center);
}
line(from, to, color, width = 1) {
this._line(from.x, from.y, to.x, to.y, color, width);
}
polyline(points, color, width = 1) {
for (let i = 1; i < points.length; i++) {
const x1 = points[i - 1].x;
const y1 = points[i - 1].y;
this._line(x1, y1, points[i].x, points[i].y, width, color);
}
}
setBackground(color) {
this.buffer.setGlobalBackground(color);
}
background(x, y, color) {
this.buffer.setBackground(x, y, color);
}
polygon(rings, color) {
const vertices = [];
const holes = [];
for (const ring of rings) {
if (vertices.length) {
if (ring.length < 3) continue;
holes.push(vertices.length / 2);
} else {
if (ring.length < 3) return false;
}
for (const point of ring) {
vertices.push(point.x);
vertices.push(point.y);
}
}
let triangles;
try {
triangles = earcut(vertices, holes);
} catch (error) {
return false;
}
for (let i = 0; i < triangles.length; i += 3) {
const pa = this._polygonExtract(vertices, triangles[i]);
const pb = this._polygonExtract(vertices, triangles[i + 1]);
const pc = this._polygonExtract(vertices, triangles[i + 2]);
this._filledTriangle(pa, pb, pc, color);
}
return true;
}
_polygonExtract(vertices, pointId) {
return [vertices[pointId * 2], vertices[pointId * 2 + 1]];
}
// Inspired by Alois Zingl's "The Beauty of Bresenham's Algorithm"
// -> http://members.chello.at/~easyfilter/bresenham.html
_line(x0, y0, x1, y1, width, color) {
// Fall back to width-less bresenham algorithm if we dont have a width
if (!(width = Math.max(0, width - 1))) {
return bresenham(x0, y0, x1, y1, (x, y) => {
return this.buffer.setPixel(x, y, color);
});
}
const dx = Math.abs(x1 - x0);
const sx = x0 < x1 ? 1 : -1;
const dy = Math.abs(y1 - y0);
const sy = y0 < y1 ? 1 : -1;
let err = dx - dy;
const ed = dx + dy === 0 ? 1 : Math.sqrt(dx * dx + dy * dy);
width = (width + 1) / 2;
/* eslint-disable no-constant-condition */
while (true) {
this.buffer.setPixel(x0, y0, color);
let e2 = err;
let x2 = x0;
if (2 * e2 >= -dx) {
e2 += dy;
let y2 = y0;
while (e2 < ed * width && (y1 !== y2 || dx > dy)) {
this.buffer.setPixel(x0, y2 += sy, color);
e2 += dx;
}
if (x0 === x1) {
break;
}
e2 = err;
err -= dy;
x0 += sx;
}
if (2 * e2 <= dy) {
e2 = dx - e2;
while (e2 < ed * width && (x1 !== x2 || dx < dy)) {
this.buffer.setPixel(x2 += sx, y0, color);
e2 += dy;
}
if (y0 === y1) {
break;
}
err += dx;
y0 += sy;
}
}
/* eslint-enable */
}
_filledRectangle(x, y, width, height, color) {
const pointA = [x, y];
const pointB = [x + width, y];
const pointC = [x, y + height];
const pointD = [x + width, y + height];
this._filledTriangle(pointA, pointB, pointC, color);
this._filledTriangle(pointC, pointB, pointD, color);
}
_bresenham(pointA, pointB) {
return bresenham(pointA[0], pointA[1], pointB[0], pointB[1]);
}
// Draws a filled triangle
_filledTriangle(pointA, pointB, pointC, color) {
const a = this._bresenham(pointB, pointC);
const b = this._bresenham(pointA, pointC);
const c = this._bresenham(pointA, pointB);
const points = a.concat(b).concat(c).filter((point) => {
var ref;
return (0 <= (ref = point.y) && ref < this.height);
}).sort(function(a, b) {
if (a.y === b.y) {
return a.x - b.x;
} else {
return a.y - b.y;
}
});
for (let i = 0; i < points.length; i++) {
const point = points[i];
const next = points[i * 1 + 1];
if (point.y === (next || {}).y) {
const left = Math.max(0, point.x);
const right = Math.min(this.width - 1, next.x);
if (left >= 0 && right <= this.width) {
for (let x = left; x <= right; x++) {
this.buffer.setPixel(x, point.y, color);
}
}
} else {
this.buffer.setPixel(point.x, point.y, color);
}
if (!next) {
break;
}
}
}
}
Canvas.prototype.stack = [];
module.exports = Canvas;

View File

@ -1,45 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Using 2D spatial indexing to avoid overlapping labels and markers
and to find labels underneath a mouse cursor's position
###
rbush = require 'rbush'
stringWidth = require 'string-width'
module.exports = class LabelBuffer
tree: null
margin: 5
constructor: (@width, @height) ->
@tree = rbush()
clear: ->
@tree.clear()
project: (x, y) ->
[Math.floor(x/2), Math.floor(y/4)]
writeIfPossible: (text, x, y, feature, margin = @margin) ->
point = @project x, y
if @_hasSpace text, point[0], point[1]
data = @_calculateArea text, point[0], point[1], margin
data.feature = feature
@tree.insert data
else
false
featuresAt: (x, y) ->
@tree.search minX: x, maxX: x, minY: y, maxY: y
_hasSpace: (text, x, y) ->
not @tree.collides @_calculateArea text, x, y
_calculateArea: (text, x, y, margin = 0) ->
minX: x-margin
minY: y-margin/2
maxX: x+margin+stringWidth(text)
maxY: y+margin/2

57
src/LabelBuffer.js Normal file
View File

@ -0,0 +1,57 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Using 2D spatial indexing to avoid overlapping labels and markers
and to find labels underneath a mouse cursor's position
*/
'use strict';
const Rbush = require('rbush');
const stringWidth = require('string-width');
module.exports = class LabelBuffer {
constructor() {
this.tree = Rbush();
this.margin = 5;
}
clear() {
this.tree.clear();
}
project(x, y) {
return [Math.floor(x/2), Math.floor(y/4)];
}
writeIfPossible(text, x, y, feature, margin) {
margin = margin || this.margin;
const point = this.project(x, y);
if (this._hasSpace(text, point[0], point[1])) {
const data = this._calculateArea(text, point[0], point[1], margin);
data.feature = feature;
return this.tree.insert(data);
} else {
return false;
}
}
featuresAt(x, y) {
this.tree.search({minX: x, maxX: x, minY: y, maxY: y});
}
_hasSpace(text, x, y) {
return !this.tree.collides(this._calculateArea(text, x, y));
}
_calculateArea(text, x, y, margin = 0) {
return {
minX: x-margin,
minY: y-margin/2,
maxX: x+margin+stringWidth(text),
maxY: y+margin/2,
};
}
};

View File

@ -1,226 +0,0 @@
###
mapscii - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
UI and central command center
###
keypress = require 'keypress'
TermMouse = require 'term-mouse'
Promise = require 'bluebird'
Renderer = require './Renderer'
TileSource = require './TileSource'
utils = require './utils'
config = require './config'
module.exports = class Mapscii
width: null
height: null
canvas: null
mouse: null
mouseDragging: false
mousePosition:
x: 0, y: 0
tileSource: null
renderer: null
zoom: 0
center:
# sf lat: 37.787946, lon: -122.407522
# iceland lat: 64.124229, lon: -21.811552
# rgbg
# lat: 49.019493, lon: 12.098341
lat: 52.51298, lon: 13.42012
minZoom: null
constructor: (options) ->
config[key] = val for key, val of options
init: ->
Promise
.resolve()
.then =>
unless config.headless
@_initKeyboard()
@_initMouse()
@_initTileSource()
.then =>
@_initRenderer()
.then =>
@_draw()
.then => @notify("Welcome to MapSCII! Use your cursors to navigate, a/z to zoom, q to quit.")
_initTileSource: ->
@tileSource = new TileSource()
@tileSource.init config.source
_initKeyboard: ->
keypress config.input
config.input.setRawMode true if config.input.setRawMode
config.input.resume()
config.input.on 'keypress', (ch, key) => @_onKey key
_initMouse: ->
@mouse = TermMouse input: config.input, output: config.output
@mouse.start()
@mouse.on 'click', (event) => @_onClick event
@mouse.on 'scroll', (event) => @_onMouseScroll event
@mouse.on 'move', (event) => @_onMouseMove event
_initRenderer: ->
@renderer = new Renderer config.output, @tileSource
@renderer.loadStyleFile config.styleFile
config.output.on 'resize', =>
@_resizeRenderer()
@_draw()
@_resizeRenderer()
@zoom = if config.initialZoom isnt null then config.initialZoom else @minZoom
_resizeRenderer: (cb) ->
if config.size
@width = config.size.width
@height = config.size.height
else
@width = config.output.columns >> 1 << 2
@height = config.output.rows * 4 - 4
@minZoom = 4-Math.log(4096/@width)/Math.LN2
@renderer.setSize @width, @height
_updateMousePosition: (event) ->
projected =
x: (event.x-.5)*2
y: (event.y-.5)*4
size = utils.tilesizeAtZoom @zoom
[dx, dy] = [projected.x-@width/2, projected.y-@height/2]
z = utils.baseZoom @zoom
center = utils.ll2tile @center.lon, @center.lat, z
@mousePosition = utils.normalize utils.tile2ll center.x+(dx/size), center.y+(dy/size), z
_onClick: (event) ->
return if event.x < 0 or event.x > @width/2 or event.y < 0 or event.y > @height/4
@_updateMousePosition event
if @mouseDragging and event.button is "left"
@mouseDragging = false
else
@setCenter @mousePosition.lat, @mousePosition.lon
@_draw()
_onMouseScroll: (event) ->
@_updateMousePosition event
# TODO: handle .x/y for directed zoom
@zoomBy config.zoomStep * if event.button is "up" then 1 else -1
@_draw()
_onMouseMove: (event) ->
return if event.x < 0 or event.x > @width/2 or event.y < 0 or event.y > @height/4
return if config.mouseCallback and not config.mouseCallback event
# start dragging
if event.button is "left"
if @mouseDragging
dx = (@mouseDragging.x-event.x)*2
dy = (@mouseDragging.y-event.y)*4
size = utils.tilesizeAtZoom @zoom
newCenter = utils.tile2ll @mouseDragging.center.x+(dx/size),
@mouseDragging.center.y+(dy/size),
utils.baseZoom(@zoom)
@setCenter newCenter.lat, newCenter.lon
@_draw()
else
@mouseDragging =
x: event.x,
y: event.y,
center: utils.ll2tile @center.lon, @center.lat, utils.baseZoom(@zoom)
@_updateMousePosition event
@notify @_getFooter()
_onKey: (key) ->
if config.keyCallback and not config.keyCallback key
return
# check if the pressed key is configured
draw = switch key?.name
when "q"
if config.quitCallback
config.quitCallback()
else
process.exit 0
when "a" then @zoomBy config.zoomStep
when "z", "y"
@zoomBy -config.zoomStep
when "left" then @moveBy 0, -8/Math.pow(2, @zoom)
when "right" then @moveBy 0, 8/Math.pow(2, @zoom)
when "up" then @moveBy 6/Math.pow(2, @zoom), 0
when "down" then @moveBy -6/Math.pow(2, @zoom), 0
when "c"
config.useBraille = !config.useBraille
true
else
null
if draw isnt null
@_draw()
_draw: ->
@renderer
.draw @center, @zoom
.then (frame) =>
@_write frame
@notify @_getFooter()
.catch =>
@notify "renderer is busy"
_getFooter: ->
# tile = utils.ll2tile @center.lon, @center.lat, @zoom
# "tile: #{utils.digits tile.x, 3}, #{utils.digits tile.x, 3} "+
"center: #{utils.digits @center.lat, 3}, #{utils.digits @center.lon, 3} "+
"zoom: #{utils.digits @zoom, 2} "+
"mouse: #{utils.digits @mousePosition.lat, 3}, #{utils.digits @mousePosition.lon, 3} "
notify: (text) ->
config.onUpdate() if config.onUpdate
@_write "\r\x1B[K"+text unless config.headless
_write: (output) ->
config.output.write output
zoomBy: (step) ->
return @zoom = @minZoom if @zoom+step < @minZoom
return @zoom = config.maxZoom if @zoom+step > config.maxZoom
@zoom += step
moveBy: (lat, lon) ->
@setCenter @center.lat+lat, @center.lon+lon
setCenter: (lat, lon) ->
@center = utils.normalize lon: lon, lat: lat

288
src/Mapscii.js Normal file
View File

@ -0,0 +1,288 @@
/*
mapscii - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
UI and central command center
*/
'use strict';
const keypress = require('keypress');
const TermMouse = require('term-mouse');
const Renderer = require('./Renderer');
const TileSource = require('./TileSource');
const utils = require('./utils');
let config = require('./config');
class Mapscii {
constructor(options) {
this.width = null;
this.height = null;
this.canvas = null;
this.mouse = null;
this.mouseDragging = false;
this.mousePosition = {
x: 0,
y: 0,
};
this.tileSource = null;
this.renderer = null;
this.zoom = 0;
// sf lat: 37.787946, lon: -122.407522
// iceland lat: 64.124229, lon: -21.811552
// rgbg
// lat: 49.019493, lon: 12.098341
this.center = {
lat: 52.51298,
lon: 13.42012,
};
this.minZoom = null;
config = Object.assign(config, options);
}
init() {
return new Promise((resolve) => {
if (!config.headless) {
this._initKeyboard();
this._initMouse();
}
this._initTileSource();
this._initRenderer();
this._draw();
this.notify('Welcome to MapSCII! Use your cursors to navigate, a/z to zoom, q to quit.');
resolve();
});
}
_initTileSource() {
this.tileSource = new TileSource();
this.tileSource.init(config.source);
}
_initKeyboard() {
keypress(config.input);
if (config.input.setRawMode) {
config.input.setRawMode(true);
}
config.input.resume();
config.input.on('keypress', (ch, key) => this._onKey(key));
}
_initMouse() {
this.mouse = TermMouse({
input: config.input,
output: config.output,
});
this.mouse.start();
this.mouse.on('click', (event) => this._onClick(event));
this.mouse.on('scroll', (event) => this._onMouseScroll(event));
this.mouse.on('move', (event) => this._onMouseMove(event));
}
_initRenderer() {
this.renderer = new Renderer(config.output, this.tileSource);
this.renderer.loadStyleFile(config.styleFile);
config.output.on('resize', () => {
this._resizeRenderer();
this._draw();
});
this._resizeRenderer();
this.zoom = (config.initialZoom !== null) ? config.initialZoom : this.minZoom;
}
_resizeRenderer() {
if (config.size) {
this.width = config.size.width;
this.height = config.size.height;
} else {
this.width = config.output.columns >> 1 << 2;
this.height = config.output.rows * 4 - 4;
}
this.minZoom = 4-Math.log(4096/this.width)/Math.LN2;
this.renderer.setSize(this.width, this.height);
}
_updateMousePosition(event) {
const projected = {
x: (event.x-0.5)*2,
y: (event.y-0.5)*4,
};
const size = utils.tilesizeAtZoom(this.zoom);
const [dx, dy] = [projected.x-this.width/2, projected.y-this.height/2];
const z = utils.baseZoom(this.zoom);
const center = utils.ll2tile(this.center.lon, this.center.lat, z);
this.mousePosition = utils.normalize(utils.tile2ll(center.x+(dx/size), center.y+(dy/size), z));
}
_onClick(event) {
if (event.x < 0 || event.x > this.width/2 || event.y < 0 || event.y > this.height/4) {
return;
}
this._updateMousePosition(event);
if (this.mouseDragging && event.button === 'left') {
this.mouseDragging = false;
} else {
this.setCenter(this.mousePosition.lat, this.mousePosition.lon);
}
this._draw();
}
_onMouseScroll(event) {
this._updateMousePosition(event);
// TODO: handle .x/y for directed zoom
this.zoomBy(config.zoomStep * (event.button === 'up' ? 1 : -1));
this._draw();
}
_onMouseMove(event) {
if (event.x < 0 || event.x > this.width/2 || event.y < 0 || event.y > this.height/4) {
return;
}
if (config.mouseCallback && !config.mouseCallback(event)) {
return;
}
// start dragging
if (event.button === 'left') {
if (this.mouseDragging) {
const dx = (this.mouseDragging.x-event.x)*2;
const dy = (this.mouseDragging.y-event.y)*4;
const size = utils.tilesizeAtZoom(this.zoom);
const newCenter = utils.tile2ll(
this.mouseDragging.center.x+(dx/size),
this.mouseDragging.center.y+(dy/size),
utils.baseZoom(this.zoom)
);
this.setCenter(newCenter.lat, newCenter.lon);
this._draw();
} else {
this.mouseDragging = {
x: event.x,
y: event.y,
center: utils.ll2tile(this.center.lon, this.center.lat, utils.baseZoom(this.zoom)),
};
}
}
this._updateMousePosition(event);
this.notify(this._getFooter());
}
_onKey(key) {
if (config.keyCallback && !config.keyCallback(key)) return;
if (!key || !key.name) return;
// check if the pressed key is configured
let draw = true;
switch (key.name) {
case 'q':
if (config.quitCallback) {
config.quitCallback();
} else {
process.exit(0);
}
break;
case 'a':
this.zoomBy(config.zoomStep);
break;
case 'y':
case 'z':
this.zoomBy(-config.zoomStep);
break;
case 'left':
this.moveBy(0, -8/Math.pow(2, this.zoom));
break;
case 'right':
this.moveBy(0, 8/Math.pow(2, this.zoom));
break;
case 'up':
this.moveBy(6/Math.pow(2, this.zoom), 0);
break;
case 'down':
this.moveBy(-6/Math.pow(2, this.zoom), 0);
break;
case 'c':
config.useBraille = !config.useBraille;
break;
default:
draw = false;
}
if (draw !== null) {
this._draw();
}
}
_draw() {
this.renderer.draw(this.center, this.zoom).then((frame) => {
this._write(frame);
this.notify(this._getFooter());
}).catch(() => {
this.notify('renderer is busy');
});
}
_getFooter() {
// tile = utils.ll2tile(this.center.lon, this.center.lat, this.zoom);
// `tile: ${utils.digits(tile.x, 3)}, ${utils.digits(tile.x, 3)} `+
return `center: ${utils.digits(this.center.lat, 3)}, ${utils.digits(this.center.lon, 3)} `+
`zoom: ${utils.digits(this.zoom, 2)} `+
`mouse: ${utils.digits(this.mousePosition.lat, 3)}, ${utils.digits(this.mousePosition.lon, 3)} `;
}
notify(text) {
config.onUpdate && config.onUpdate();
if (!config.headless) {
this._write('\r\x1B[K' + text);
}
}
_write(output) {
config.output.write(output);
}
zoomBy(step) {
if (this.zoom+step < this.minZoom) {
return this.zoom = this.minZoom;
}
if (this.zoom+step > config.maxZoom) {
return this.zoom = config.maxZoom;
}
this.zoom += step;
}
moveBy(lat, lon) {
this.setCenter(this.center.lat+lat, this.center.lon+lon);
}
setCenter(lat, lon) {
this.center = utils.normalize({
lon: lon,
lat: lat,
});
}
}
module.exports = Mapscii;

View File

@ -1,267 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
The Console Vector Tile renderer - bäm!
###
Promise = require 'bluebird'
x256 = require 'x256'
simplify = require 'simplify-js'
Canvas = require './Canvas'
LabelBuffer = require './LabelBuffer'
Styler = require './Styler'
Tile = require './Tile'
utils = require './utils'
config = require './config'
module.exports = class Renderer
terminal:
CLEAR: "\x1B[2J"
MOVE: "\x1B[?6h"
isDrawing: false
lastDrawAt: 0
labelBuffer: null
tileSource: null
tilePadding: 64
constructor: (@output, @tileSource) ->
@labelBuffer = new LabelBuffer()
loadStyleFile: (file) ->
@styler = new Styler file
@tileSource.useStyler @styler
setSize: (@width, @height) ->
@canvas = new Canvas @width, @height
draw: (center, zoom) ->
return Promise.reject() if @isDrawing
@isDrawing = true
@labelBuffer.clear()
@_seen = {}
if color = @styler.styleById['background']?.paint['background-color']
@canvas.setBackground x256 utils.hex2rgb color
@canvas.clear()
Promise
.resolve @_visibleTiles center, zoom
.map (tile) => @_getTile tile
.map (tile) => @_getTileFeatures tile, zoom
.then (tiles) => @_renderTiles tiles
.then => @_getFrame()
.catch (e) ->
console.log e
.finally (frame) =>
@isDrawing = false
@lastDrawAt = Date.now()
frame
_visibleTiles: (center, zoom) ->
z = utils.baseZoom zoom
center = utils.ll2tile center.lon, center.lat, z
tiles = []
tileSize = utils.tilesizeAtZoom zoom
for y in [Math.floor(center.y)-1..Math.floor(center.y)+1]
for x in [Math.floor(center.x)-1..Math.floor(center.x)+1]
tile = x: x, y: y, z: z
position =
x: @width/2-(center.x-tile.x)*tileSize
y: @height/2-(center.y-tile.y)*tileSize
gridSize = Math.pow 2, z
tile.x %= gridSize
if tile.x < 0
tile.x = if z is 0 then 0 else tile.x+gridSize
if tile.y < 0 or
tile.y >= gridSize or
position.x+tileSize < 0 or
position.y+tileSize < 0 or
position.x>@width or
position.y>@height
continue
tiles.push xyz: tile, zoom: zoom, position: position, size: tileSize
tiles
_getTile: (tile) ->
@tileSource
.getTile tile.xyz.z, tile.xyz.x, tile.xyz.y
.then (data) =>
tile.data = data
tile
_getTileFeatures: (tile, zoom) ->
position = tile.position
layers = {}
for layerId in @_generateDrawOrder zoom
continue unless layer = tile.data.layers?[layerId]
scale = layer.extent / utils.tilesizeAtZoom zoom
layers[layerId] =
scale: scale
features: layer.tree.search
minX: -position.x*scale
minY: -position.y*scale
maxX: (@width-position.x)*scale
maxY: (@height-position.y)*scale
tile.layers = layers
tile
_renderTiles: (tiles) ->
drawn = {}
labels = []
for layerId in @_generateDrawOrder tiles[0].xyz.z
for tile in tiles
continue unless layer = tile.layers[layerId]
for feature in layer.features
# continue if feature.id and drawn[feature.id]
# drawn[feature.id] = true
if layerId.match /label/
labels.push tile: tile, feature: feature, scale: layer.scale
else
@_drawFeature tile, feature, layer.scale
labels.sort (a, b) ->
a.feature.sorty-b.feature.sort
for label in labels
@_drawFeature label.tile, label.feature, label.scale
_getFrame: ->
frame = ""
frame += @terminal.CLEAR unless @lastDrawAt
frame += @terminal.MOVE
frame += @canvas.frame()
frame
featuresAt: (x, y) ->
@labelBuffer.featuresAt x, y
_drawFeature: (tile, feature, scale) ->
if feature.style.minzoom and tile.zoom < feature.style.minzoom
return false
else if feature.style.maxzoom and tile.zoom > feature.style.maxzoom
return false
switch feature.style.type
when "line"
width = feature.style.paint['line-width']
# TODO: apply the correct zoom based value
width = width.stops[0][1] if width instanceof Object
points = @_scaleAndReduce tile, feature, feature.points, scale
@canvas.polyline points, feature.color, width if points.length
when "fill"
points = (@_scaleAndReduce tile, feature, p, scale, false for p in feature.points)
@canvas.polygon points, feature.color
when "symbol"
text = feature.label or
genericSymbol = config.poiMarker
return false if @_seen[text] and not genericSymbol
placed = false
for point in @_scaleAndReduce tile, feature, feature.points, scale
x = point.x - text.length
margin = config.layers[feature.layer]?.margin or config.labelMargin
if @labelBuffer.writeIfPossible text, x, point.y, feature, margin
@canvas.text text, x, point.y, feature.color
placed = true
break
else if config.layers[feature.layer]?.cluster and
@labelBuffer.writeIfPossible config.poiMarker, point.x, point.y, feature, 3
@canvas.text config.poiMarker, point.x, point.y, feature.color
placed = true
break
@_seen[text] = true if placed
true
_scaleAndReduce: (tile, feature, points, scale, filter = true) ->
lastX = lastY = outside = null
scaled = []
minX = minY = -@tilePadding
maxX = @width+@tilePadding
maxY = @height+@tilePadding
for point in points
x = Math.floor tile.position.x+(point.x/scale)
y = Math.floor tile.position.y+(point.y/scale)
continue if lastX is x and lastY is y
lastY = y
lastX = x
if filter
if x < minX or x > maxX or y < minY or y > maxY
continue if outside
outside = true
else
if outside
outside = null
scaled.push x: lastX, y: lastY
scaled.push x: x, y: y
if feature.style.type isnt "symbol"
if scaled.length < 2
return []
if config.simplifyPolylines
simplify scaled, .5, true
else
scaled
else
scaled
_generateDrawOrder: (zoom) ->
if zoom < 2
[
"admin"
"water"
"country_label"
"marine_label"
]
else
[
"landuse"
"water"
"marine_label"
"building"
"road"
"admin"
"country_label"
"state_label"
"water_label"
"place_label"
"rail_station_label"
"poi_label"
"road_label"
"housenum_label"
]

343
src/Renderer.js Normal file
View File

@ -0,0 +1,343 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
The Console Vector Tile renderer - bäm!
*/
'use strict';
const Promise = require('bluebird');
const x256 = require('x256');
const simplify = require('simplify-js');
const Canvas = require('./Canvas');
const LabelBuffer = require('./LabelBuffer');
const Styler = require('./Styler');
const utils = require('./utils');
const config = require('./config');
class Renderer {
constructor(output, tileSource) {
this.output = output;
this.tileSource = tileSource;
this.labelBuffer = new LabelBuffer();
}
loadStyleFile(file) {
this.styler = new Styler(file);
this.tileSource.useStyler(this.styler);
}
setSize(width, height) {
this.width = width;
this.height = height;
this.canvas = new Canvas(width, height);
}
draw(center, zoom) {
if (this.isDrawing) return Promise.reject();
this.isDrawing = true;
this.labelBuffer.clear();
this._seen = {};
let ref;
const color = ((ref = this.styler.styleById['background']) !== null ?
ref.paint['background-color']
:
void 0
);
if (color) {
this.canvas.setBackground(x256(utils.hex2rgb(color)));
}
this.canvas.clear();
return Promise.resolve(this._visibleTiles(center, zoom)).map((tile) => {
return this._getTile(tile);
}).map((tile) => {
return this._getTileFeatures(tile, zoom);
}).then((tiles) => {
this._renderTiles(tiles);
}).then(() => {
return this._getFrame();
}).catch((e) => {
return console.log(e);
}).finally((frame) => {
this.isDrawing = false;
this.lastDrawAt = Date.now();
return frame;
});
}
_visibleTiles(center, zoom) {
const z = utils.baseZoom(zoom);
center = utils.ll2tile(center.lon, center.lat, z);
const tiles = [];
const tileSize = utils.tilesizeAtZoom(zoom);
for (let y = Math.floor(center.y) - 1; y <= Math.floor(center.y) + 1; y++) {
for (let x = Math.floor(center.x) - 1; x <= Math.floor(center.x) + 1; x++) {
const tile = {x, y, z};
const position = {
x: this.width / 2 - (center.x - tile.x) * tileSize,
y: this.height / 2 - (center.y - tile.y) * tileSize,
};
const gridSize = Math.pow(2, z);
tile.x %= gridSize;
if (tile.x < 0) {
tile.x = z === 0 ? 0 : tile.x + gridSize;
}
if (tile.y < 0 || tile.y >= gridSize || position.x + tileSize < 0 || position.y + tileSize < 0 || position.x > this.width || position.y > this.height) {
continue;
}
tiles.push({
xyz: tile,
zoom,
position,
size: tileSize,
});
}
}
return tiles;
}
_getTile(tile) {
return this.tileSource
.getTile(tile.xyz.z, tile.xyz.x, tile.xyz.y)
.then((data) => {
tile.data = data;
return tile;
});
}
_getTileFeatures(tile, zoom) {
const position = tile.position;
const layers = {};
const drawOrder = this._generateDrawOrder(zoom);
for (const layerId of drawOrder) {
const layer = (tile.data.layers || {})[layerId];
if (!layer) {
continue;
}
const scale = layer.extent / utils.tilesizeAtZoom(zoom);
layers[layerId] = {
scale: scale,
features: layer.tree.search({
minX: -position.x * scale,
minY: -position.y * scale,
maxX: (this.width - position.x) * scale,
maxY: (this.height - position.y) * scale
}),
};
}
tile.layers = layers;
return tile;
}
_renderTiles(tiles) {
const labels = [];
const drawOrder = this._generateDrawOrder(tiles[0].xyz.z);
for (const layerId of drawOrder) {
for (const tile of tiles) {
const layer = tile.layers[layerId];
if (!layer) continue;
for (const feature of layer.features) {
// continue if feature.id and drawn[feature.id]
// drawn[feature.id] = true;
if (layerId.match(/label/)) {
labels.push({
tile,
feature,
scale: layer.scale
});
} else {
this._drawFeature(tile, feature, layer.scale);
}
}
}
}
labels.sort((a, b) => {
return a.feature.sorty - b.feature.sort;
});
for (const label of labels) {
this._drawFeature(label.tile, label.feature, label.scale);
}
}
_getFrame() {
let frame = '';
if (!this.lastDrawAt) {
frame += this.terminal.CLEAR;
}
frame += this.terminal.MOVE;
frame += this.canvas.frame();
return frame;
}
featuresAt(x, y) {
return this.labelBuffer.featuresAt(x, y);
}
_drawFeature(tile, feature, scale) {
let points, placed;
if (feature.style.minzoom && tile.zoom < feature.style.minzoom) {
return false;
} else if (feature.style.maxzoom && tile.zoom > feature.style.maxzoom) {
return false;
}
switch (feature.style.type) {
case 'line': {
let width = feature.style.paint['line-width'];
if (width instanceof Object) {
// TODO: apply the correct zoom based value
width = width.stops[0][1];
}
points = this._scaleAndReduce(tile, feature, feature.points, scale);
if (points.length) {
this.canvas.polyline(points, feature.color, width);
}
break;
}
case 'fill': {
points = feature.points.map((p) => {
return this._scaleAndReduce(tile, feature, p, scale, false);
});
this.canvas.polygon(points, feature.color);
break;
}
case 'symbol': {
const genericSymbol = config.poiMarker;
const text = feature.label || config.poiMarker;
if (this._seen[text] && !genericSymbol) {
return false;
}
placed = false;
const pointsOfInterest = this._scaleAndReduce(tile, feature, feature.points, scale);
for (const point of pointsOfInterest) {
const x = point.x - text.length;
const layerMargin = (config.layers[feature.layer] || {}).margin;
const margin = layerMargin || config.labelMargin;
if (this.labelBuffer.writeIfPossible(text, x, point.y, feature, margin)) {
this.canvas.text(text, x, point.y, feature.color);
placed = true;
break;
} else {
const cluster = (config.layers[feature.layer] || {}).cluster;
if (cluster && this.labelBuffer.writeIfPossible(config.poiMarker, point.x, point.y, feature, 3)) {
this.canvas.text(config.poiMarker, point.x, point.y, feature.color);
placed = true;
break;
}
}
}
if (placed) {
this._seen[text] = true;
}
break;
}
}
return true;
}
_scaleAndReduce(tile, feature, points, scale, filter = true) {
let lastX;
let lastY;
let outside;
const scaled = [];
const minX = -this.tilePadding;
const minY = -this.tilePadding;
const maxX = this.width + this.tilePadding;
const maxY = this.height + this.tilePadding;
for (const point of points) {
const x = Math.floor(tile.position.x + (point.x / scale));
const y = Math.floor(tile.position.y + (point.y / scale));
if (lastX === x && lastY === y) {
continue;
}
lastY = y;
lastX = x;
if (filter) {
if (x < minX || x > maxX || y < minY || y > maxY) {
if (outside) {
continue;
}
outside = true;
} else {
if (outside) {
outside = null;
scaled.push({x: lastX, y: lastY});
}
}
}
scaled.push({x, y});
}
if (feature.style.type !== 'symbol') {
if (scaled.length < 2) {
return [];
}
if (config.simplifyPolylines) {
return simplify(scaled, .5, true);
} else {
return scaled;
}
} else {
return scaled;
}
}
_generateDrawOrder(zoom) {
if (zoom < 2) {
return [
'admin',
'water',
'country_label',
'marine_label',
];
} else {
return [
'landuse',
'water',
'marine_label',
'building',
'road',
'admin',
'country_label',
'state_label',
'water_label',
'place_label',
'rail_station_label',
'poi_label',
'road_label',
'housenum_label',
];
}
}
}
Renderer.prototype.terminal = {
CLEAR: '\x1B[2J',
MOVE: '\x1B[?6h',
};
Renderer.prototype.isDrawing = false;
Renderer.prototype.lastDrawAt = 0;
Renderer.prototype.labelBuffer = null;
Renderer.prototype.tileSource = null;
Renderer.prototype.tilePadding = 64;
module.exports = Renderer;

View File

@ -1,112 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Minimalistic parser and compiler for Mapbox (Studio) Map Style files
See: https://www.mapbox.com/mapbox-gl-style-spec/
Compiles layer filter instructions into a chain of true/false returning
anonymous functions to improve rendering speed compared to realtime parsing.
###
fs = require 'fs'
module.exports = class Styler
styleById: {}
styleByLayer: {}
constructor: (file) ->
json = JSON.parse fs.readFileSync(file).toString()
@styleName = json.name
@_replaceConstants json.constants, json.layers if json.constants
for style in json.layers
if style.ref and @styleById[style.ref]
for ref in ['type', 'source-layer', 'minzoom', 'maxzoom', 'filter']
if @styleById[style.ref][ref] and not style[ref]
style[ref] = @styleById[style.ref][ref]
style.appliesTo = @_compileFilter style.filter
@styleByLayer[style['source-layer']] ?= []
@styleByLayer[style['source-layer']].push style
@styleById[style.id] = style
getStyleFor: (layer, feature, zoom) ->
return false unless @styleByLayer[layer]
for style in @styleByLayer[layer]
if style.appliesTo feature
return style
return false
_replaceConstants: (constants, tree) ->
for id, node of tree
switch typeof node
when 'object'
continue if node.constructor.name.match /Stream/
@_replaceConstants constants, node
when 'string'
if node.charAt(0) is '@'
tree[id] = constants[node]
null
_compileFilter: (filter) ->
switch filter?[0]
when "all"
filters = (@_compileFilter subFilter for subFilter in filter[1..])
(feature) ->
return false for appliesTo in filters when not appliesTo feature
true
when "any"
filters = (@_compileFilter subFilter for subFilter in filter[1..])
(feature) ->
return true for appliesTo in filters when appliesTo feature
false
when "none"
filters = (@_compileFilter subFilter for subFilter in filter[1..])
(feature) ->
return false for appliesTo in filters when appliesTo feature
true
when "=="
(feature) -> feature.properties[filter[1]] is filter[2]
when "!="
(feature) -> feature.properties[filter[1]] isnt filter[2]
when "in"
(feature) ->
return true for value in filter[2..] when feature.properties[filter[1]] is value
false
when "!in"
(feature) ->
return false for value in filter[2..] when feature.properties[filter[1]] is value
true
when "has"
(feature) -> !!feature.properties[filter[1]]
when "!has"
(feature) -> !feature.properties[filter[1]]
when ">"
(feature) -> feature.properties[filter[1]] > filter[2]
when ">="
(feature) -> feature.properties[filter[1]] >= filter[2]
when "<"
(feature) -> feature.properties[filter[1]] < filter[2]
when "<="
(feature) -> feature.properties[filter[1]] <= filter[2]
else
-> true

136
src/Styler.js Normal file
View File

@ -0,0 +1,136 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Minimalistic parser and compiler for Mapbox (Studio) Map Style files
See: https://www.mapbox.com/mapbox-gl-style-spec/
Compiles layer filter instructions into a chain of true/false returning
anonymous functions to improve rendering speed compared to realtime parsing.
*/
'use strict';
const fs = require('fs');
class Styler {
constructor(file) {
this.styleById = {};
this.styleByLayer = {};
var base, name;
const json = JSON.parse(fs.readFileSync(file).toString());
this.styleName = json.name;
if (json.constants) {
this._replaceConstants(json.constants, json.layers);
}
for (const style of json.layers) {
if (style.ref && this.styleById[style.ref]) {
for (const ref of ['type', 'source-layer', 'minzoom', 'maxzoom', 'filter']) {
if (this.styleById[style.ref][ref] && !style[ref]) {
style[ref] = this.styleById[style.ref][ref];
}
}
}
style.appliesTo = this._compileFilter(style.filter);
//TODO Better translation of: @styleByLayer[style['source-layer']] ?= []
if ((base = this.styleByLayer)[name = style['source-layer']] == null) {
base[name] = [];
}
this.styleByLayer[style['source-layer']].push(style);
this.styleById[style.id] = style;
}
}
getStyleFor(layer, feature) {
if (!this.styleByLayer[layer]) {
return false;
}
for (const style of this.styleByLayer[layer]) {
if (style.appliesTo(feature)) {
return style;
}
}
return false;
}
_replaceConstants(constants, tree) {
for (const id in tree) {
const node = tree[id];
switch (typeof node) {
case 'object':
if (node.constructor.name.match(/Stream/)) {
continue;
}
this._replaceConstants(constants, node);
break;
case 'string':
if (node.charAt(0) === '@') {
tree[id] = constants[node];
}
}
}
}
//TODO Better translation of the long cases.
_compileFilter(filter) {
let filters;
switch (filter != null ? filter[0] : void 0) {
case 'all':
filter = filter.slice(1);
filters = (() => {
return filter.map((sub) => this._compileFilter(sub));
}).call(this);
return (feature) => !!filters.find((appliesTo) => {
return !appliesTo(feature);
});
case 'any':
filter = filter.slice(1);
filters = (() => {
return filter.map((sub) => this._compileFilter(sub));
}).call(this);
return (feature) => !!filters.find((appliesTo) => {
return appliesTo(feature);
});
case 'none':
filter = filter.slice(1);
filters = (() => {
return filter.map((sub) => this._compileFilter(sub));
}).call(this);
return (feature) => !filters.find((appliesTo) => {
return !appliesTo(feature);
});
case '==':
return (feature) => feature.properties[filter[1]] === filter[2];
case '!=':
return (feature) => feature.properties[filter[1]] !== filter[2];
case 'in':
return (feature) => !!filter.slice(2).find((value) => {
return feature.properties[filter[1]] === value;
});
case '!in':
return (feature) => !filter.slice(2).find((value) => {
return feature.properties[filter[1]] === value;
});
case 'has':
return (feature) => !!feature.properties[filter[1]];
case '!has':
return (feature) => !feature.properties[filter[1]];
case '>':
return (feature) => feature.properties[filter[1]] > filter[2];
case '>=':
return (feature) => feature.properties[filter[1]] >= filter[2];
case '<':
return (feature) => feature.properties[filter[1]] < filter[2];
case '<=':
return (feature) => feature.properties[filter[1]] <= filter[2];
default:
return () => true;
}
}
}
module.exports = Styler;

View File

@ -1,153 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Handling of and access to single VectorTiles
###
VectorTile = require('@mapbox/vector-tile').VectorTile
Protobuf = require 'pbf'
Promise = require 'bluebird'
zlib = require 'zlib'
rbush = require 'rbush'
x256 = require 'x256'
earcut = require 'earcut'
config = require "./config"
utils = require "./utils"
class Tile
layers: {}
constructor: (@styler) ->
load: (buffer) ->
@_unzipIfNeeded buffer
.then (buffer) => @_loadTile buffer
.then => @_loadLayers()
.then => this
_loadTile: (buffer) ->
@tile = new VectorTile new Protobuf buffer
_unzipIfNeeded: (buffer) ->
new Promise (resolve, reject) =>
if @_isGzipped buffer
zlib.gunzip buffer, (err, data) ->
return reject err if err
resolve data
else
resolve buffer
_isGzipped: (buffer) ->
buffer.slice(0,2).indexOf(Buffer.from([0x1f, 0x8b])) is 0
_loadLayers: () ->
layers = {}
colorCache = {}
for name, layer of @tile.layers
nodes = []
#continue if name is "water"
for i in [0...layer.length]
# TODO: caching of similar attributes to avoid looking up the style each time
#continue if @styler and not @styler.getStyleFor layer, feature
feature = layer.feature i
feature.properties.$type = type = [undefined, "Point", "LineString", "Polygon"][feature.type]
if @styler
style = @styler.getStyleFor name, feature
continue unless style
color =
style.paint['line-color'] or
style.paint['fill-color'] or
style.paint['text-color']
# TODO: style zoom stops handling
if color instanceof Object
color = color.stops[0][1]
colorCode = colorCache[color] or colorCache[color] = x256 utils.hex2rgb color
# TODO: monkey patching test case for tiles with a reduced extent 4096 / 8 -> 512
# use feature.loadGeometry() again as soon as we got a 512 extent tileset
geometries = feature.loadGeometry() #@_reduceGeometry feature, 8
sort = feature.properties.localrank or feature.properties.scalerank
label = if style.type is "symbol"
feature.properties["name_"+config.language] or
feature.properties.name_en or
feature.properties.name or
feature.properties.house_num
else
undefined
if style.type is "fill"
nodes.push @_addBoundaries true,
# id: feature.id
layer: name
style: style
label: label
sort: sort
points: geometries
color: colorCode
else
for points in geometries
nodes.push @_addBoundaries false,
# id: feature.id
layer: name
style: style
label: label
sort: sort
points: points
color: colorCode
tree = rbush 18
tree.load nodes
layers[name] =
extent: layer.extent
tree: tree
@layers = layers
_addBoundaries: (deep, data) ->
minX = Infinity
maxX = -Infinity
minY = Infinity
maxY = -Infinity
for p in (if deep then data.points[0] else data.points)
minX = p.x if p.x < minX
maxX = p.x if p.x > maxX
minY = p.y if p.y < minY
maxY = p.y if p.y > maxY
data.minX = minX
data.maxX = maxX
data.minY = minY
data.maxY = maxY
data
_reduceGeometry: (feature, factor) ->
for points, i in feature.loadGeometry()
reduced = []
last = null
for point in points
p =
x: Math.floor point.x/factor
y: Math.floor point.y/factor
if last and last.x is p.x and last.y is p.y
continue
reduced.push last = p
reduced
module.exports = Tile

167
src/Tile.js Normal file
View File

@ -0,0 +1,167 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Handling of and access to single VectorTiles
*/
'use strict';
const VectorTile = require('@mapbox/vector-tile').VectorTile;
const Protobuf = require('pbf');
const zlib = require('zlib');
const rbush = require('rbush');
const x256 = require('x256');
const config = require('./config');
const utils = require('./utils');
class Tile {
constructor(styler) {
this.styler = styler;
}
load(buffer) {
return this._unzipIfNeeded(buffer).then((buffer) => {
return this._loadTile(buffer);
}).then(() => {
return this._loadLayers();
}).then(() => {
return this;
});
}
_loadTile(buffer) {
this.tile = new VectorTile(new Protobuf(buffer));
}
_unzipIfNeeded(buffer) {
return new Promise((resolve, reject) => {
if (this._isGzipped(buffer)) {
zlib.gunzip(buffer, (err, data) => {
if (err) {
reject(err);
}
resolve(data);
});
} else {
resolve(buffer);
}
});
}
_isGzipped(buffer) {
return buffer.slice(0, 2).indexOf(Buffer.from([0x1f, 0x8b])) === 0;
}
_loadLayers() {
const layers = {};
const colorCache = {};
for (const name in this.tile.layers) {
const layer = this.tile.layers[name];
const nodes = [];
//continue if name is 'water'
for (let i = 0; i < layer.length; i++) {
// TODO: caching of similar attributes to avoid looking up the style each time
//continue if @styler and not @styler.getStyleFor layer, feature
const feature = layer.feature(i);
feature.properties.$type = [undefined, 'Point', 'LineString', 'Polygon'][feature.type];
let style;
if (this.styler) {
style = this.styler.getStyleFor(name, feature);
if (!style) {
continue;
}
}
let color = (
style.paint['line-color'] ||
style.paint['fill-color'] ||
style.paint['text-color']
);
// TODO: style zoom stops handling
if (color instanceof Object) {
color = color.stops[0][1];
}
const colorCode = colorCache[color] || (colorCache[color] = x256(utils.hex2rgb(color)));
// TODO: monkey patching test case for tiles with a reduced extent 4096 / 8 -> 512
// use feature.loadGeometry() again as soon as we got a 512 extent tileset
const geometries = feature.loadGeometry(); //@_reduceGeometry feature, 8
const sort = feature.properties.localrank || feature.properties.scalerank;
const label = style.type === 'symbol' ? feature.properties['name_' + config.language] || feature.properties.name_en || feature.properties.name || feature.properties.house_num : void 0;
if (style.type === 'fill') {
nodes.push(this._addBoundaries(true, {
// id: feature.id
layer: name,
style,
label,
sort,
points: geometries,
color: colorCode,
}));
} else {
for (const points of geometries) {
nodes.push(this._addBoundaries(false, {
//id: feature.id,
layer: name,
style,
label,
sort,
points,
color: colorCode,
}));
}
}
}
const tree = rbush(18);
tree.load(nodes);
layers[name] = {
extent: layer.extent,
tree,
};
}
return this.layers = layers;
}
_addBoundaries(deep, data) {
let minX = 2e308;
let maxX = -2e308;
let minY = 2e308;
let maxY = -2e308;
const points = (deep ? data.points[0] : data.points);
for (const p of points) {
if (p.x < minX) minX = p.x;
if (p.x > maxX) maxX = p.x;
if (p.y < minY) minY = p.y;
if (p.y > maxY) maxY = p.y;
}
data.minX = minX;
data.maxX = maxX;
data.minY = minY;
data.maxY = maxY;
return data;
}
_reduceGeometry(feature, factor) {
const results = [];
const geometries = feature.loadGeometry();
for (const points of geometries) {
const reduced = [];
let last;
for (const point of points) {
const p = {
x: Math.floor(point.x / factor),
y: Math.floor(point.y / factor)
};
if (last && last.x === p.x && last.y === p.y) {
continue;
}
reduced.push(last = p);
}
results.push(reduced);
}
return results;
}
}
Tile.prototype.layers = {};
module.exports = Tile;

View File

@ -1,133 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Source for VectorTiles - supports
* remote TileServer
* local MBTiles and VectorTiles
###
Promise = require 'bluebird'
userhome = require 'userhome'
fetch = require 'node-fetch'
fs = require 'fs'
Tile = require './Tile'
config = require './config'
# https://github.com/mapbox/node-mbtiles has native build dependencies (sqlite3)
# To maximize mapscii's compatibility, MBTiles support must be manually added via
# $> npm install -g mbtiles
MBTiles = try
require 'mbtiles'
catch
null
module.exports = class TileSource
cache: {}
cacheSize: 16
cached: []
modes:
MBTiles: 1
VectorTile: 2
HTTP: 3
mode: null
mbtiles: null
styler: null
init: (@source) ->
if @source.startsWith "http"
@_initPersistence() if config.persistDownloadedTiles
@mode = @modes.HTTP
else if @source.endsWith ".mbtiles"
unless MBTiles
throw new Error "MBTiles support must be installed with following command: 'npm install -g mbtiles'"
@mode = @modes.MBTiles
@loadMBtils source
else
throw new Error "source type isn't supported yet"
loadMBtils: (source) ->
new Promise (resolve, reject) =>
new MBTiles source, (err, @mbtiles) =>
if err then reject err
else resolve()
useStyler: (@styler) ->
getTile: (z, x, y) ->
unless @mode
throw new Error "no TileSource defined"
z = Math.max 0, Math.floor z
if cached = @cache[[z,x,y].join("-")]
return Promise.resolve cached
if @cached.length > @cacheSize
for tile in @cached.splice 0, Math.abs(@cacheSize-@cached.length)
delete @cache[tile]
switch @mode
when @modes.MBTiles then @_getMBTile z, x, y
when @modes.HTTP then @_getHTTP z, x, y
_getHTTP: (z, x, y) ->
promise =
if config.persistDownloadedTiles and tile = @_getPersited z, x, y
Promise.resolve tile
else
fetch @source+[z,x,y].join("/")+".pbf"
.then (res) => res.buffer()
.then (buffer) =>
@_persistTile z, x, y, buffer if config.persistDownloadedTiles
buffer
promise
.then (buffer) =>
@_createTile z, x, y, buffer
_getMBTile: (z, x, y) ->
new Promise (resolve, reject) =>
@mbtiles.getTile z, x, y, (err, buffer) =>
return reject err if err
resolve @_createTile z, x, y, buffer
_createTile: (z, x, y, buffer) ->
name = [z,x,y].join("-")
@cached.push name
tile = @cache[name] = new Tile @styler
tile.load buffer
_initPersistence: ->
try
@_createFolder userhome ".mapscii"
@_createFolder userhome ".mapscii", "cache"
catch error
config.persistDownloadedTiles = false
return
_persistTile: (z, x, y, buffer) ->
zoom = z.toString()
@_createFolder userhome ".mapscii", "cache", zoom
fs.writeFile userhome(".mapscii", "cache", zoom, "#{x}-#{y}.pbf"), buffer, -> null
_getPersited: (z, x, y) ->
try
fs.readFileSync userhome ".mapscii", "cache", z.toString(), "#{x}-#{y}.pbf"
catch error
false
_createFolder: (path) ->
try
fs.mkdirSync path
true
catch e
e.code is "EEXIST"

176
src/TileSource.js Normal file
View File

@ -0,0 +1,176 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
Source for VectorTiles - supports
* remote TileServer
* local MBTiles and VectorTiles
*/
'use strict';
const userhome = require('userhome');
const fetch = require('node-fetch');
const fs = require('fs');
const Tile = require('./Tile');
const config = require('./config');
// https://github.com/mapbox/node-mbtiles has native build dependencies (sqlite3)
// To maximize mapscii's compatibility, MBTiles support must be manually added via
// $> npm install -g mbtiles
let MBTiles = null;
try {
MBTiles = require('mbtiles');
} catch (err) {void 0;}
const modes = {
MBTiles: 1,
VectorTile: 2,
HTTP: 3,
};
class TileSource {
init(source) {
this.source = source;
this.cache = {};
this.cacheSize = 16;
this.cached = [];
this.mode = null;
this.mbtiles = null;
this.styler = null;
if (this.source.startsWith('http')) {
if (config.persistDownloadedTiles) {
this._initPersistence();
}
this.mode = modes.HTTP;
} else if (this.source.endsWith('.mbtiles')) {
if (!MBTiles) {
throw new Error('MBTiles support must be installed with following command: \'npm install -g mbtiles\'');
}
this.mode = modes.MBTiles;
this.loadMBtils(source);
} else {
throw new Error('source type isn\'t supported yet');
}
}
loadMBtils(source) {
return new Promise((resolve, reject) => {
new MBTiles(source, (err, mbtiles) => {
if (err) {
reject(err);
}
this.mbtiles = mbtiles;
resolve();
});
});
}
useStyler(styler) {
this.styler = styler;
}
getTile(z, x, y) {
if (!this.mode) {
throw new Error('no TileSource defined');
}
const cached = this.cache[[z, x, y].join('-')];
if (cached) {
return Promise.resolve(cached);
}
if (this.cached.length > this.cacheSize) {
const overflow = Math.abs(this.cacheSize - this.cache.length);
for (const tile in this.cached.splice(0, overflow)) {
delete this.cache[tile];
}
}
switch (this.mode) {
case modes.MBTiles:
return this._getMBTile(z, x, y);
case modes.HTTP:
return this._getHTTP(z, x, y);
}
}
_getHTTP(z, x, y) {
let promise;
const persistedTile = this._getPersited(z, x, y);
if (config.persistDownloadedTiles && persistedTile) {
promise = Promise.resolve(persistedTile);
} else {
promise = fetch(this.source + [z,x,y].join('/') + '.pbf')
.then((res) => res.buffer())
.then((buffer) => {
if (config.persistDownloadedTiles) {
this._persistTile(z, x, y, buffer);
return buffer;
}
});
}
return promise.then((buffer) => {
return this._createTile(z, x, y, buffer);
});
}
_getMBTile(z, x, y) {
return new Promise((resolve, reject) => {
this.mbtiles.getTile(z, x, y, (err, buffer) => {
if (err) {
reject(err);
}
resolve(this._createTile(z, x, y, buffer));
});
});
}
_createTile(z, x, y, buffer) {
const name = [z, x, y].join('-');
this.cached.push(name);
const tile = this.cache[name] = new Tile(this.styler);
return tile.load(buffer);
}
_initPersistence() {
try {
this._createFolder(userhome('.mapscii'));
this._createFolder(userhome('.mapscii', 'cache'));
} catch (error) {
config.persistDownloadedTiles = false;
}
}
_persistTile(z, x, y, buffer) {
const zoom = z.toString();
this._createFolder(userhome('.mapscii', 'cache', zoom));
const filePath = userhome('.mapscii', 'cache', zoom, `${x}-${y}.pbf`);
return fs.writeFile(filePath, buffer, () => null);
}
_getPersited(z, x, y) {
try {
return fs.readFileSync(userhome('.mapscii', 'cache', z.toString(), `${x}-${y}.pbf`));
} catch (error) {
return false;
}
}
_createFolder(path) {
try {
fs.mkdirSync(path);
return true;
} catch (error) {
return error.code === 'EEXIST';
}
}
}
module.exports = TileSource;

View File

@ -1,41 +0,0 @@
module.exports =
language: "en"
# TODO: adapt to osm2vectortiles successor openmaptiles v3)
# mapscii.me hosts the last available version, 2016-06-20
source: "http://mapscii.me/"
#source: __dirname+"/../mbtiles/regensburg.mbtiles"
styleFile: __dirname+"/../styles/dark.json"
initialZoom: null
maxZoom: 18
zoomStep: 0.2
simplifyPolylines: false
useBraille: true
# Downloaded files get persisted in ~/.mapscii
persistDownloadedTiles: true
tileRange: 14
projectSize: 256
labelMargin: 5
layers:
housenum_label: margin: 4
poi_label: cluster: true, margin: 5
place_label: cluster: true
state_label: cluster: true
input: process.stdin
output: process.stdout
headless: false
delimeter: "\n\r"
poiMarker: ""

52
src/config.js Normal file
View File

@ -0,0 +1,52 @@
module.exports = {
language: 'en',
// TODO: adapt to osm2vectortiles successor openmaptiles v3)
// mapscii.me hosts the last available version, 2016-06-20
source: 'http://mapscii.me/',
//source: __dirname+"/../mbtiles/regensburg.mbtiles",
styleFile: __dirname+'/../styles/dark.json',
initialZoom: null,
maxZoom: 18,
zoomStep: 0.2,
simplifyPolylines: false,
useBraille: true,
// Downloaded files get persisted in ~/.mapscii
persistDownloadedTiles: true,
tileRange: 14,
projectSize: 256,
labelMargin: 5,
layers: {
housenum_label: {
margin: 4
},
poi_label: {
cluster: true,
margin: 5,
},
place_label: {
cluster: true,
},
state_label: {
cluster: true,
},
},
input: process.stdin,
output: process.stdout,
headless: false,
delimeter: '\n\r',
poiMarker: '◉',
};

View File

@ -1,74 +0,0 @@
###
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
methods used all around
###
config = require './config'
constants =
RADIUS: 6378137
utils =
clamp: (num, min, max) ->
if num <= min then min else if num >= max then max else num
baseZoom: (zoom) ->
Math.min config.tileRange, Math.max 0, Math.floor zoom
tilesizeAtZoom: (zoom) ->
config.projectSize * Math.pow(2, zoom-utils.baseZoom(zoom))
deg2rad: (angle) ->
# (angle / 180) * Math.PI
angle * 0.017453292519943295
ll2tile: (lon, lat, zoom) ->
x: (lon+180)/360*Math.pow(2, zoom)
y: (1-Math.log(Math.tan(lat*Math.PI/180)+1/Math.cos(lat*Math.PI/180))/Math.PI)/2*Math.pow(2, zoom)
z: zoom
tile2ll: (x, y, zoom) ->
n = Math.PI - 2*Math.PI*y/Math.pow(2, zoom)
lon: x/Math.pow(2, zoom)*360-180
lat: 180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n)))
metersPerPixel: (zoom, lat = 0) ->
(Math.cos(lat * Math.PI/180) * 2 * Math.PI * constants.RADIUS) / (256 * Math.pow(2, zoom))
hex2rgb: (color) ->
return [255, 0, 0] unless color?.match
unless color.match /^#[a-fA-F0-9]{3,6}$/
throw new Error "#{color} isn\'t a supported hex color"
color = color.substr 1
decimal = parseInt color, 16
if color.length is 3
rgb = [decimal>>8, (decimal>>4)&15, decimal&15]
rgb.map (c) => c + (c<<4)
else
[(decimal>>16)&255, (decimal>>8)&255, decimal&255]
digits: (number, digits) ->
Math.floor(number*Math.pow(10, digits))/Math.pow(10, digits)
normalize: (ll) ->
ll.lon += 360 if ll.lon < -180
ll.lon -= 360 if ll.lon > 180
ll.lat = 85.0511 if ll.lat > 85.0511
ll.lat = -85.0511 if ll.lat < -85.0511
ll
population: (val) ->
bits = 0
while val>0
bits += val & 1
val >>= 1
bits
module.exports = utils

103
src/utils.js Normal file
View File

@ -0,0 +1,103 @@
/*
termap - Terminal Map Viewer
by Michael Strassburger <codepoet@cpan.org>
methods used all around
*/
'use strict';
const config = require('./config');
const constants = {
RADIUS: 6378137,
};
const utils = {
clamp: (num, min, max) => {
if (num <= min) {
return min;
} else if (num >= max) {
return max;
} else {
return num;
}
},
baseZoom: (zoom) => {
return Math.min(config.tileRange, Math.max(0, Math.floor(zoom)));
},
tilesizeAtZoom: (zoom) => {
return config.projectSize * Math.pow(2, zoom-utils.baseZoom(zoom));
},
deg2rad: (angle) => {
// (angle / 180) * Math.PI
return angle * 0.017453292519943295;
},
ll2tile: (lon, lat, zoom) => {
return {
x: (lon+180)/360*Math.pow(2, zoom),
y: (1-Math.log(Math.tan(lat*Math.PI/180)+1/Math.cos(lat*Math.PI/180))/Math.PI)/2*Math.pow(2, zoom),
z: zoom,
};
},
tile2ll: (x, y, zoom) => {
const n = Math.PI - 2*Math.PI*y/Math.pow(2, zoom);
return {
lon: x/Math.pow(2, zoom)*360-180,
lat: 180/Math.PI*Math.atan(0.5*(Math.exp(n)-Math.exp(-n))),
};
},
metersPerPixel: (zoom, lat = 0) => {
return (Math.cos(lat * Math.PI/180) * 2 * Math.PI * constants.RADIUS) / (256 * Math.pow(2, zoom));
},
hex2rgb: (color) => {
if (typeof color !== 'string') return [255, 0, 0];
if (/^#[a-fA-F0-9]{3,6}$/.test()) {
throw new Error('#{color} isn\'t a supported hex color');
}
color = color.substr(1);
const decimal = parseInt(color, 16);
if (color.length === 3) {
const rgb = [decimal>>8, (decimal>>4)&15, decimal&15];
return rgb.map((c) => {
return c + (c<<4);
});
} else {
return [(decimal>>16)&255, (decimal>>8)&255, decimal&255];
}
},
digits: (number, digits) => {
return Math.floor(number*Math.pow(10, digits))/Math.pow(10, digits);
},
normalize: (ll) => {
if (ll.lon < -180) ll.lon += 360;
if (ll.lon > 180) ll.lon -= 360;
if (ll.lat > 85.0511) ll.lat = 85.0511;
if (ll.lat < -85.0511) ll.lat = -85.0511;
return ll;
},
population: (val) => {
let bits = 0;
while (val > 0) {
bits += val & 1;
val >>= 1;
}
return bits;
},
};
module.exports = utils;