mirror of
https://github.com/rastapasta/mapscii.git
synced 2024-11-07 16:54:22 +01:00
commit
7a313c8a7c
29
.eslintrc.js
Normal file
29
.eslintrc.js
Normal 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"
|
||||
]
|
||||
}
|
||||
};
|
@ -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
|
||||
|
||||
|
5
main.js
5
main.js
@ -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
1095
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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
211
src/BrailleBuffer.js
Normal 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;
|
@ -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
202
src/Canvas.js
Normal 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;
|
@ -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
57
src/LabelBuffer.js
Normal 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,
|
||||
};
|
||||
}
|
||||
};
|
@ -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
288
src/Mapscii.js
Normal 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;
|
@ -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
343
src/Renderer.js
Normal 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;
|
@ -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
136
src/Styler.js
Normal 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;
|
153
src/Tile.coffee
153
src/Tile.coffee
@ -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
167
src/Tile.js
Normal 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;
|
@ -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
176
src/TileSource.js
Normal 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;
|
@ -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
52
src/config.js
Normal 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: '◉',
|
||||
};
|
@ -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
103
src/utils.js
Normal 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;
|
Loading…
Reference in New Issue
Block a user