Console: Add world map with locations of routers and tunnels

This commit is contained in:
zzz
2025-04-08 12:56:04 +00:00
parent 21189b0a02
commit fd4fc237af
13 changed files with 1362 additions and 2 deletions
@@ -0,0 +1,610 @@
package net.i2p.router.web.helpers;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.awt.image.ImageObserver;
import javax.imageio.ImageIO;
import javax.imageio.stream.ImageOutputStream;
import javax.imageio.stream.MemoryCacheImageOutputStream;
import net.i2p.data.DataHelper;
import net.i2p.data.Hash;
import net.i2p.data.router.RouterInfo;
import net.i2p.rrd4j.SimpleSVGGraphics2D;
import static net.i2p.rrd4j.SimpleSVGGraphics2D.*;
import net.i2p.rrd4j.SimpleSVGMaker;
import net.i2p.router.RouterContext;
import net.i2p.router.TunnelInfo;
import net.i2p.router.TunnelManagerFacade;
import net.i2p.router.tunnel.HopConfig;
import net.i2p.router.tunnel.pool.TunnelPool;
import net.i2p.router.web.ContextHelper;
import net.i2p.router.web.Messages;
import net.i2p.util.Log;
import net.i2p.util.ObjectCounterUnsafe;
import net.i2p.util.Translate;
/**
* Generate a transparent image to overlay the world map in a Web Mercator format.
*
* Also contains commented-out code to generate the mercator.txt file.
*
* @since 0.9.xx
*/
public class MapMaker {
private final RouterContext _context;
private final Log _log;
private final Map<Object, Object> hints = new HashMap<Object, Object>(4);
private static final Map<String, Mercator> _mercator = new HashMap<String, Mercator>(256);
static {
readMercatorFile();
}
private static final String MERCATOR_DEFAULT = "mercator.txt";
private static final int WIDTH = 1600;
private static final int HEIGHT = 1600;
private static final int MAP_HEIGHT = 828;
// offsets from mercator to image.
// left side at 171.9 degrees (rotated 34 pixels)
// tweak to make it line up, eyeball Taiwan
private static final int IMG_X_OFF = -34;
// We crop the top from 85 degrees down to about 75 degrees (283 pixels)
// We crop the bottom from 85 degrees down to about 57 degrees (489 pixels)
private static final int IMG_Y_OFF = -283;
private static final Color TEXT_COLOR = new Color(20, 20, 20);
private static final String FONT_NAME = "Dialog";
private static final int FONT_STYLE = Font.BOLD;
private static final int FONT_SIZE = 16;
// center text on the spot
private static final int TEXT_Y_OFF = (FONT_SIZE / 2) - 2;
private static final Color CIRCLE_BORDER_COLOR = new Color(192, 0, 0, 192);
private static final Color CIRCLE_COLOR = new Color(160, 0, 0, 128);
private static final double CIRCLE_SIZE_FACTOR = 2.75;
private static final int MIN_CIRCLE_SIZE = 7;
private static final Color SQUARE_BORDER_COLOR = new Color(0, 0, 0);
private static final Color SQUARE_COLOR = new Color(255, 50, 255, 160);
private static final Color CLIENT_COLOR = new Color(255, 100, 0);
private static final Color EXPL_COLOR = new Color(255, 160, 160);
private static final Color PART_COLOR = new Color(255, 0, 100);
private static final Color ANIMATE_COLOR = new Color(100, 0, 255);
private static final Color TRANSPARENT = new Color(0, 0, 0, 0);
private static final BasicStroke STROKE = new BasicStroke(1);
private static final BasicStroke STROKE2 = new BasicStroke(3);
private static final int MODE_ROUTERS = 1;
private static final int MODE_EXPL = 2;
private static final int MODE_CLIENT = 4;
private static final int MODE_PART = 8;
private static final int MODE_ANIM = 16;
private static final int MODE_FF = 32;
private static final int MODE_DEFAULT = MODE_ROUTERS | MODE_EXPL | MODE_CLIENT | MODE_ANIM;
private int tunnelCount;
/**
*
*/
public MapMaker() {
this(ContextHelper.getContext(null));
}
/**
*
*/
public MapMaker(RouterContext ctx) {
_context = ctx;
_log = ctx.logManager().getLog(MapMaker.class);
}
private static class Mercator {
public final int x, y;
public Mercator(int x, int y) {
this.x = x; this.y = y;
}
@Override
public int hashCode() {
return x + y;
}
@Override
public boolean equals(Object o) {
Mercator m = (Mercator) o;
return x == m.x && y == m.y;
}
}
/**
* @param mode bitmask, 0 for default, see MODE definitions above
*/
public boolean render(int mode, OutputStream out) throws IOException {
if (_mercator.isEmpty()) {
_log.warn("mercator file not found");
return false;
}
out.write(render(mode).getBytes("UTF-8"));
out.flush();
return true;
}
/**
* @param mode see above
*/
public String render(int mode) throws IOException {
if (mode == 0)
mode = MODE_DEFAULT;
ObjectCounterUnsafe<String> countries = new ObjectCounterUnsafe<String>();
if ((mode & (MODE_ROUTERS | MODE_FF)) != 0) {
boolean ff = (mode & MODE_FF) != 0;
for (RouterInfo ri : _context.netDb().getRouters()) {
if (ff && ri.getCapabilities().indexOf('f') < 0)
continue;
Hash key = ri.getIdentity().getHash();
String country = _context.commSystem().getCountry(key);
if (country != null)
countries.increment(country);
}
}
return render(mode, countries);
}
/**
* @param mode see above
*/
public String render(int mode, ObjectCounterUnsafe<String> countries) throws IOException {
// only for string widths
Graphics2D gg = new SimpleSVGGraphics2D(1, 1);
StringBuilder buf = new StringBuilder(32768);
SimpleSVGMaker g = new SimpleSVGMaker(buf);
g.startSVG(WIDTH, MAP_HEIGHT, TRANSPARENT, "mapoverlaysvg", null);
Font large = new Font(FONT_NAME, FONT_STYLE, FONT_SIZE);
buf.append("<a href=\"/netdb?f=16\">");
g.drawText(_t("Routers"), 25, 700, TEXT_COLOR, large, null, hints);
buf.append("</a>\n");
buf.append("<a href=\"/netdb?f=512\">");
g.drawText(_t("Floodfills"), 25, 725, TEXT_COLOR, large, null, hints);
buf.append("</a>\n");
buf.append("<a href=\"/netdb?f=32\">");
g.drawText(_t("Exploratory Tunnels"), 25, 750, TEXT_COLOR, large, null, hints);
buf.append("</a>\n");
buf.append("<a href=\"/netdb?f=64\">");
g.drawText(_t("Client Tunnels"), 25, 775, TEXT_COLOR, large, null, hints);
buf.append("</a>\n");
buf.append("<a href=\"/netdb?f=128\">");
g.drawText(_t("Participating Tunnels"), 25, 800, TEXT_COLOR, large, null, hints);
buf.append("</a>\n");
if ((mode & (MODE_ROUTERS | MODE_FF)) != 0) {
for (String c : countries.objects()) {
Mercator m = _mercator.get(c);
if (m == null)
continue;
int count = countries.count(c);
String title = getTranslatedCountry(c) + ": " + ngettext("{0} router", "{0} routers", count);
hints.put(KEY_ELEMENT_ID, "mapoverlaytext-" + c);
hints.put(KEY_ELEMENT_CLASS, "mapoverlaytext dynamic");
//hints.put(KEY_ELEMENT_TITLE, title);
c = c.toUpperCase(Locale.US);
double width = getStringWidth(c, large, gg);
int xoff = (int) (width / 2);
g.drawText(c.toUpperCase(Locale.US), rotate(m.x) - xoff, m.y + IMG_Y_OFF + TEXT_Y_OFF, TEXT_COLOR, large, null, hints);
hints.clear();
// put the circle on top of the text so it captures the title
int sz = Math.max(MIN_CIRCLE_SIZE, (int) (CIRCLE_SIZE_FACTOR * Math.sqrt(count)));
// add count to ID so it will be replaced on change by ajaxchanges
hints.put(KEY_ELEMENT_ID, "mapoverlaycircle-" + c + '-' + count);
hints.put(KEY_ELEMENT_CLASS, "mapoverlaycircle dynamic");
hints.put(KEY_ELEMENT_TITLE, title);
drawCircle(g, rotate(m.x), m.y + IMG_Y_OFF, sz);
hints.clear();
}
}
String us = _context.commSystem().getOurCountry();
if (us != null) {
Mercator mus = _mercator.get(us);
if (mus != null) {
hints.put(KEY_ELEMENT_ID, "mapoverlaysquare-me");
hints.put(KEY_ELEMENT_CLASS, "mapoverlaysquare");
hints.put(KEY_ELEMENT_TITLE, _t("My router"));
drawSquare(g, rotate(mus.x), mus.y + IMG_Y_OFF, 24);
if ((mode & (MODE_EXPL | MODE_CLIENT)) != 0) {
TunnelManagerFacade tm = _context.tunnelManager();
tunnelCount = 0;
if ((mode & MODE_EXPL) != 0) {
renderPool(mode, g, mus, tm.getInboundExploratoryPool(), EXPL_COLOR);
renderPool(mode, g, mus, tm.getOutboundExploratoryPool(), EXPL_COLOR);
}
if ((mode & MODE_CLIENT) != 0) {
Map<Hash, TunnelPool> pools = tm.getInboundClientPools();
for (TunnelPool tp : pools.values()) {
if (tp.getSettings().getAliasOf() != null)
continue;
renderPool(mode, g, mus, tp, CLIENT_COLOR);
}
pools = tm.getOutboundClientPools();
for (TunnelPool tp : pools.values()) {
if (tp.getSettings().getAliasOf() != null)
continue;
renderPool(mode, g, mus, tp, CLIENT_COLOR);
}
}
}
if ((mode & MODE_PART) != 0) {
ObjectCounterUnsafe<String> tunnels = new ObjectCounterUnsafe<String>();
List<HopConfig> participating = _context.tunnelDispatcher().listParticipatingTunnels();
for (int i = 0; i < participating.size(); i++) {
HopConfig cfg = participating.get(i);
Hash from = cfg.getReceiveFrom();
Hash to = cfg.getSendTo();
String c = null;
if (from != null) {
c = _context.commSystem().getCountry(from);
if (c != null)
tunnels.increment(c);
}
if (to != null) {
String d = _context.commSystem().getCountry(to);
// only count once if to and from same country
if (d != null && !d.equals(c))
tunnels.increment(d);
}
}
renderParticipating(g, mus, tunnels);
}
}
}
g.endSVG();
return buf.toString();
}
/**
* Draw circle centered on x,y with a radius given
*/
private void drawCircle(SimpleSVGMaker g, int x, int y, int radius) {
g.drawCircle(x, y, radius, CIRCLE_BORDER_COLOR, CIRCLE_COLOR, STROKE, null, hints);
}
/**
* Draw square centered on x,y with a width/height given
*/
private void drawSquare(SimpleSVGMaker g, int x, int y, int sz) {
g.drawSquare(x, y, sz, SQUARE_BORDER_COLOR, SQUARE_COLOR, STROKE, null, hints);
}
/*
* @param mode see above
*/
private void renderPool(int mode, SimpleSVGMaker g, Mercator mus, TunnelPool tp, Color color) {
boolean isInbound = tp.getSettings().isInbound();
boolean isExpl = tp.getSettings().isExploratory();
// shift to 4 corners of box
int off = 12;
if (isExpl) {
if (isInbound)
mus = new Mercator(mus.x - off, mus.y - off);
else
mus = new Mercator(mus.x + off, mus.y - off);
} else {
if (isInbound)
mus = new Mercator(mus.x - off, mus.y + off);
else
mus = new Mercator(mus.x + off, mus.y + off);
}
List<TunnelInfo> tunnels = tp.listTunnels();
List<Mercator> hops = new ArrayList<Mercator>(8);
String nick = isExpl ? null : tp.getSettings().getDestinationNickname();
int[] x = new int[8];
int[] y = new int[8];
for (TunnelInfo info : tunnels) {
int length = info.getLength();
if (length < 2)
continue;
StringBuilder cbuf = new StringBuilder(16);
if (!isInbound)
cbuf.append("(me)");
// gateway first
for (int j = 0; j < length; j++) {
Mercator m;
if (isInbound && j == length - 1) {
m = mus;
} else if (!isInbound && j == 0) {
m = mus;
} else {
if (cbuf.length() > 0)
cbuf.append("->"); // SVGMaker will escape
Hash peer = info.getPeer(j);
String country = _context.commSystem().getCountry(peer);
if (country == null) {
cbuf.append('?');
continue;
}
cbuf.append(country.toUpperCase(Locale.US));
Mercator mc = _mercator.get(country);
if (mc == null)
continue;
m = mc;
}
if (hops.isEmpty() || !m.equals(hops.get(hops.size() - 1))) {
hops.add(m);
}
}
if (isInbound)
cbuf.append("->(me)"); // SVGMaker will escape
int sz = hops.size();
if (sz > 1) {
for (int i = 0; i < sz; i++) {
Mercator m = hops.get(i);
x[i] = rotate(m.x);
y[i] = m.y + IMG_Y_OFF;
}
long tid = isInbound ? info.getReceiveTunnelId(length - 1).getTunnelId()
: info.getSendTunnelId(0).getTunnelId();
String svgid = "mapoverlaytunnel-" + tid;
String title;
if (isInbound) {
if (isExpl)
title = _t("Inbound exploratory tunnel");
else
title = _t("Inbound client tunnel");
} else {
if (isExpl)
title = _t("Outbound exploratory tunnel");
else
title = _t("Outbound client tunnel");
}
if (nick != null)
title += " (" + nick + ')';
title += " " + tid + " " + cbuf;
hints.put(KEY_ELEMENT_ID, svgid);
hints.put(KEY_ELEMENT_CLASS, "mapoverlaytunnel dynamic");
hints.put(KEY_ELEMENT_TITLE, title);
g.drawPolyline(x, y, sz, color, STROKE2, null, hints);
hints.clear();
if ((mode & MODE_ANIM) != 0) {
hints.put(KEY_ELEMENT_ID, "mapoverlayanim-" + tid);
hints.put(KEY_ELEMENT_CLASS, "mapoverlayanim dynamic");
// 3 hops is 10 sec
String anim = "<animateMotion dur=\"" + String.format(Locale.US, "%.1f", (sz - 1) * 3.3f) + "s\" repeatCount=\"2\" " +
"onbegin=\"beginCircleAnim('mapoverlayanim-" + tid + "')\" " +
"onend=\"endCircleAnim('mapoverlayanim-" + tid + "')\" " +
// wait for line drawing animation to stop,
// and spread out the start times
"begin=\"" + (5000 + (250 * (++tunnelCount))) + "ms\" " +
" >\n" +
" <mpath href=\"#" + svgid + "\" />\n" +
" </animateMotion>";
hints.put(KEY_ELEMENT_INNERSVG, anim);
// place them off-screen until animation starts
// the js will reset to (0,0) on begin, and remove them on end
drawCircle(g, -5, 0, 5);
hints.clear();
}
} else {
if (_log.shouldDebug())
_log.debug("Can't draw tunnel path " + cbuf);
}
hops.clear();
}
}
/*
* One line to each country, width = number of tunnels
*/
private void renderParticipating(SimpleSVGMaker g, Mercator mus, ObjectCounterUnsafe<String> tunnels) {
int usx = rotate(mus.x);
int usy = mus.y + IMG_Y_OFF;
int off = 12;
for (String c : tunnels.objects()) {
Mercator m = _mercator.get(c);
if (m == null)
continue;
if (m.equals(mus))
continue;
int count = tunnels.count(c);
hints.put(KEY_ELEMENT_ID, "part-" + c + '-' + count);
hints.put(KEY_ELEMENT_CLASS, "mapoverlaytunnel dynamic");
String title = getTranslatedCountry(c) + ": " + ngettext("{0} participating tunnel", "{0} participating tunnels", count);
hints.put(KEY_ELEMENT_TITLE, title);
// shift to 4 corners of box
int mx, my;
int tx = rotate(m.x);
int ty = m.y + IMG_Y_OFF;
if (tx > usx) {
tx -= off;
mx = usx + off;
} else {
tx += off;
mx = usx - off;
}
if (ty > usy) {
ty -= off;
my = usy + off;
} else {
ty += off;
my = usy - off;
}
g.drawLine(tx, ty, mx, my, PART_COLOR,
new BasicStroke(Math.max(3, Math.min(30, 3 * count / 2))),
null, hints);
hints.clear();
}
}
private static double getStringWidth(String text, Font font, Graphics2D g) {
return font.getStringBounds(text, 0, text.length(), g.getFontRenderContext()).getBounds().getWidth();
}
private static int rotate(int x) {
x += IMG_X_OFF;
if (x < 0)
x += WIDTH;
return x;
}
/**
* Countries now in a separate bundle
* @param code two-letter country code
*/
private String getTranslatedCountry(String code) {
String name = _context.commSystem().getCountryName(code);
return Translate.getString(name, _context, Messages.COUNTRY_BUNDLE_NAME);
}
/** translate a string */
private String _t(String s) {
return Messages.getString(s, _context);
}
/**
* Read in and parse the mercator country file.
* The file need not be sorted.
* This file was created from the lat/long data at
* https://developers.google.com/public-data/docs/canonical/countries_csv
* using the convertLatLongFile() method below.
*/
private static void readMercatorFile() {
InputStream is = MapMaker.class.getResourceAsStream("/net/i2p/router/web/resources/" + MERCATOR_DEFAULT);
if (is == null) {
System.out.println("Country file not found");
return;
}
BufferedReader br = null;
try {
br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String line = null;
while ( (line = br.readLine()) != null) {
try {
if (line.charAt(0) == '#')
continue;
String[] s = DataHelper.split(line, ",", 3);
if (s.length < 3)
continue;
int x = Integer.parseInt(s[1]);
int y = Integer.parseInt(s[2]);
_mercator.put(s[0], new Mercator(x, y));
} catch (NumberFormatException nfe) {
System.out.println("Bad line " + nfe);
}
}
} catch (IOException ioe) {
System.out.println("Error reading the Country File " + ioe);
} finally {
if (is != null) try { is.close(); } catch (IOException ioe) {}
if (br != null) try { br.close(); } catch (IOException ioe) {}
}
}
/** translate a string */
private String ngettext(String s, String p, int n) {
return Messages.getString(n, s, p, _context);
}
// Following is code to convert the latlong.csv file from Google
// to our mercator.txt file which is bundled in the war.
/****
private static final String LATLONG_DEFAULT = "latlong.csv";
private static class LatLong {
public final float lat, lon;
public LatLong(float lat, float lon) {
this.lat = lat; this.lon = lon;
}
}
****/
/**
* Read in and parse the lat/long file.
* The file need not be sorted.
* Convert the lat/long data from
* https://developers.google.com/public-data/docs/canonical/countries_csv
* to a 1600x1600 web mercator (85 degree) format.
* latlong.csv input format: XX,lat,long,countryname (lat and long are signed floats)
* mercator.txt output format: xx,x,y (x and y are integers 0-1200, not adjusted for a cropped projection)
* Output is sorted by country code.
*/
/***
private static void convertLatLongFile() {
Map<String, LatLong> latlong = new HashMap<String, LatLong>();
InputStream is = null;
BufferedReader br = null;
try {
is = new FileInputStream(LATLONG_DEFAULT);
br = new BufferedReader(new InputStreamReader(is, "UTF-8"));
String line = null;
while ( (line = br.readLine()) != null) {
try {
if (line.charAt(0) == '#')
continue;
String[] s = DataHelper.split(line, ",", 4);
if (s.length < 3)
continue;
String lc = s[0].toLowerCase(Locale.US);
float lat = Float.parseFloat(s[1]);
float lon = Float.parseFloat(s[2]);
latlong.put(lc, new LatLong(lat, lon));
} catch (NumberFormatException nfe) {
System.out.println("Bad line " + nfe);
}
}
} catch (IOException ioe) {
System.out.println("Error reading the Country File " + ioe);
} finally {
if (is != null) try { is.close(); } catch (IOException ioe) {}
if (br != null) try { br.close(); } catch (IOException ioe) {}
}
Map<String, Mercator> mercator = new TreeMap<String, Mercator>();
for (Map.Entry<String, LatLong> e : latlong.entrySet()) {
String c = e.getKey();
LatLong ll = e.getValue();
mercator.put(c, convert(ll));
}
for (Map.Entry<String, Mercator> e : mercator.entrySet()) {
String c = e.getKey();
Mercator m = e.getValue();
System.out.println(c + ',' + m.x + ',' + m.y);
}
}
****/
/**
* https://stackoverflow.com/questions/57322997/convert-geolocation-to-pixels-on-a-mercator-projection-image
*/
/****
private static Mercator convert(LatLong latlong) {
double rad = latlong.lat * Math.PI / 180;
double mercn = Math.log(Math.tan((Math.PI / 4) + (rad / 2)));
double x = (latlong.lon + 180d) * (WIDTH / 360d);
double y = (HEIGHT / 2d) - ((WIDTH * mercn) / (2 * Math.PI));
return new Mercator((int) Math.round(x), (int) Math.round(y));
}
public static void main(String args[]) {
convertLatLongFile();
}
****/
}
@@ -10,6 +10,9 @@ package net.i2p.router.web.helpers;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.io.Serializable;
import java.io.Writer;
import java.math.BigInteger; // debug
@@ -58,6 +61,7 @@ import net.i2p.util.Addresses;
import net.i2p.util.ConvertToHash;
import net.i2p.util.Log;
import net.i2p.util.ObjectCounterUnsafe;
import net.i2p.util.SystemVersion;
import net.i2p.util.Translate;
import net.i2p.util.VersionComparator;
@@ -1076,6 +1080,7 @@ class NetDbRenderer {
/**
* @param mode 0: charts only; 1: full routerinfos; 2: abbreviated routerinfos
* mode 3: Same as 0 but sort countries by count
* Codes greater than 16 are map codes * 16
*/
public void renderStatusHTML(Writer out, int pageSize, int page, int mode) throws IOException {
if (!_context.netDb().isInitialized()) {
@@ -1184,7 +1189,28 @@ class NetDbRenderer {
// the summary table
buf.append("<table id=\"netdboverview\" border=\"0\" cellspacing=\"30\"><tr><th colspan=\"3\">");
buf.append(_t("Network Database Router Statistics"));
buf.append("</th></tr><tr><td style=\"vertical-align: top;\">");
buf.append("</th></tr>");
if (!SystemVersion.isSlow() && !_context.commSystem().isDummy()) {
// svg inline part 1
out.append(buf);
buf.setLength(0);
buf.append("<tr><td id=\"mapcontainer\" colspan=\"3\">");
boolean ok = embedResource(buf, "mapbase75p1.svg");
if (ok) {
out.append(buf);
buf.setLength(0);
// overlay
MapMaker mm = new MapMaker(_context);
out.write(mm.render(mode >> 4));
// svg inline part 2
embedResource(buf, "mapbase75p2.svg");
buf.append("</td></tr>");
out.append(buf);
}
buf.setLength(0);
}
mode &= 0x0f;
buf.append("<tr><td style=\"vertical-align: top;\">");
// versions table
List<String> versionList = new ArrayList<String>(versions.objects());
if (!versionList.isEmpty()) {
@@ -1288,6 +1314,30 @@ class NetDbRenderer {
out.flush();
}
/**
* @return success
* @since 0.9.66
*/
private boolean embedResource(StringBuilder buf, String rsc) {
InputStream is = this.getClass().getResourceAsStream("/net/i2p/router/web/resources/" + rsc);
if (is == null)
return false;
Reader br = null;
try {
br = new InputStreamReader(is, "UTF-8");
char[] c = new char[4096];
int read;
while ( (read = br.read(c)) >= 0) {
buf.append(c, 0, read);
}
} catch (IOException ioe) {
return false;
} finally {
if (br != null) try { br.close(); } catch (IOException ioe) {}
}
return true;
}
/**
* Countries now in a separate bundle
* @param code two-letter country code