mirror of
https://github.com/csharpee/Map-Projections.git
synced 2025-12-10 00:00:19 -05:00
I (finally) made it automatically snip paths that are obviously crossing interruptions. This should make vector maps look world better straight out of the program, and will be crucial to mass-producing vector maps. There's some room for improvement regarding the handling of closepaths, but all in all, this is a big step up. I also got a new input, fixed some minor issues with the new Cahill-Keyes (did you know it's pronounced /kais/?), and might have fixed the sporadic BufferOverflowExceptions, but I'm not sure about the last one, as that problem is extremely difficult to reproduce, but I have a strong suspicion it has something to do with threads, and I added a bunch of Platform.runLater()s, so we'll see.
499 lines
16 KiB
Java
499 lines
16 KiB
Java
/**
|
|
* MIT License
|
|
*
|
|
* Copyright (c) 2017 Justin Kunimune
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
* of this software and associated documentation files (the "Software"), to deal
|
|
* in the Software without restriction, including without limitation the rights
|
|
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the Software is
|
|
* furnished to do so, subject to the following conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be included in all
|
|
* copies or substantial portions of the Software.
|
|
*
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
* SOFTWARE.
|
|
*/
|
|
package utils;
|
|
|
|
import java.io.BufferedInputStream;
|
|
import java.io.BufferedWriter;
|
|
import java.io.File;
|
|
import java.io.FileInputStream;
|
|
import java.io.FileWriter;
|
|
import java.io.IOException;
|
|
import java.io.StringReader;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Iterator;
|
|
import java.util.LinkedList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Stack;
|
|
import java.util.function.DoubleConsumer;
|
|
|
|
import javax.xml.parsers.ParserConfigurationException;
|
|
import javax.xml.parsers.SAXParser;
|
|
import javax.xml.parsers.SAXParserFactory;
|
|
|
|
import org.xml.sax.Attributes;
|
|
import org.xml.sax.InputSource;
|
|
import org.xml.sax.SAXException;
|
|
import org.xml.sax.helpers.AttributesImpl;
|
|
import org.xml.sax.helpers.DefaultHandler;
|
|
|
|
/**
|
|
* An input equirectangular map based on an SVG file
|
|
*
|
|
* @author jkunimune
|
|
*/
|
|
public class SVGMap implements Iterable<SVGMap.Path> {
|
|
|
|
public static final double[] NULL_TRANSFORM = {1, 1, 0, 0};
|
|
|
|
private List<Path> paths; //the set of closed curves in this image
|
|
private List<String> format; //the stuff that goes between the curve descriptions, probably important for something.
|
|
private double vbMinX, vbMinY, vbWidth, vbHeight; //the SVG viewBox
|
|
private double svgWidth, svgHeight; //the actual SVG dimensions
|
|
private int length; //the total number of path commands, for optimization purposes
|
|
|
|
private static final Map<String, String> ATTRIBUTE_PLACEHOLDERS = Map.of(
|
|
"width","hmxMLwhWHeqMA8Ba", "height","VlMBunXsmQUtmCw4", "viewBox","UrFo1q9niPDkKSNC"); //attributes of the SVG object to change
|
|
|
|
|
|
|
|
public SVGMap(File file) throws IOException, SAXException, ParserConfigurationException {
|
|
paths = new LinkedList<Path>();
|
|
format = new LinkedList<String>();
|
|
|
|
final SAXParser parser = SAXParserFactory.newInstance().newSAXParser();
|
|
|
|
final DefaultHandler handler = new DefaultHandler() {
|
|
|
|
private Stack<double[]> transformStack = new Stack<double[]>();
|
|
private String currentFormatString = "";
|
|
|
|
@Override
|
|
public InputSource resolveEntity(String publicId, String systemId) {
|
|
return new InputSource(new StringReader("")); //ignore all external references - we don't need to validate
|
|
}
|
|
|
|
@Override
|
|
public void startElement(
|
|
String uri, String localName, String qName, Attributes attributes) throws SAXException {
|
|
currentFormatString += "<"+qName;
|
|
|
|
if (attributes.getIndex("transform") >= 0)
|
|
parseTransform(attributes.getValue("transform"));
|
|
else
|
|
parseTransform();
|
|
|
|
if (qName.equals("path")) {
|
|
try {
|
|
parsePath(attributes.getValue("d"));
|
|
} catch (Exception e) {
|
|
throw new SAXException(e.getLocalizedMessage(), null);
|
|
}
|
|
}
|
|
|
|
if (qName.equals("svg")) {
|
|
attributes = parseViewBox(attributes);
|
|
}
|
|
|
|
for (int i = 0; i < attributes.getLength(); i ++)
|
|
if (!attributes.getQName(i).equals("d") && //d is already taken care of
|
|
!attributes.getQName(i).equals("transform")) //there shall be no transforms in the final output
|
|
currentFormatString +=
|
|
" "+attributes.getQName(i)+"=\""+attributes.getValue(i)+"\"";
|
|
currentFormatString += ">";
|
|
}
|
|
|
|
@Override
|
|
public void endElement(String uri, String localName, String qName) {
|
|
currentFormatString += "</"+qName+">";
|
|
transformStack.pop();
|
|
}
|
|
|
|
@Override
|
|
public void characters(char[] ch, int start, int length) {
|
|
for (int i = 0; i < length; i ++) {
|
|
if (ch[start+i] >= 128)
|
|
currentFormatString += "&#" + (int)ch[start+i] + ";";
|
|
else
|
|
currentFormatString += ch[start+i];
|
|
}
|
|
}
|
|
|
|
@Override
|
|
public void endDocument() {
|
|
format.add(currentFormatString);
|
|
}
|
|
|
|
private Attributes parseViewBox(Attributes attrs) {
|
|
if (attrs.getValue("width") != null)
|
|
svgWidth = Double.parseDouble(attrs.getValue("width"));
|
|
else
|
|
svgWidth = 360.;
|
|
if (attrs.getValue("height") != null)
|
|
svgHeight = Double.parseDouble(attrs.getValue("height"));
|
|
else
|
|
svgHeight = 180.;
|
|
|
|
if (attrs.getValue("viewBox") != null) {
|
|
String[] values = attrs.getValue("viewBox").split("\\s", 4);
|
|
vbMinX = Double.parseDouble(values[0]);
|
|
vbMinY = Double.parseDouble(values[1]);
|
|
vbWidth = Double.parseDouble(values[2]);
|
|
vbHeight = Double.parseDouble(values[3]);
|
|
}
|
|
else {
|
|
vbWidth = svgWidth;
|
|
vbHeight = svgHeight;
|
|
vbMinX = 0;
|
|
vbMinY = 0;
|
|
}
|
|
|
|
AttributesImpl modAttrs = new AttributesImpl(attrs); //now insert the placeholders for the format string
|
|
for (String qName: ATTRIBUTE_PLACEHOLDERS.keySet()) {
|
|
if (modAttrs.getIndex(qName) >= 0)
|
|
modAttrs.setValue(modAttrs.getIndex(qName), ATTRIBUTE_PLACEHOLDERS.get(qName));
|
|
else
|
|
modAttrs.addAttribute("", "", qName, "", ATTRIBUTE_PLACEHOLDERS.get(qName));
|
|
}
|
|
return modAttrs;
|
|
}
|
|
|
|
private void parseTransform() {
|
|
if (transformStack.isEmpty())
|
|
transformStack.push(NULL_TRANSFORM);
|
|
else
|
|
transformStack.push(transformStack.peek());
|
|
}
|
|
|
|
private void parseTransform(String transString) {
|
|
double xScale = 1, yScale = 1, xTrans = 0, yTrans = 0;
|
|
int i;
|
|
while ((i = transString.indexOf('(')) >= 0) {
|
|
int j = transString.indexOf(')');
|
|
String type = transString.substring(0, i).trim();
|
|
String argString = transString.substring(i+1, j);
|
|
String[] args = argString.split("[,\\s]+");
|
|
if (type.equals("matrix")) {
|
|
xScale = Double.parseDouble(args[0]);
|
|
yScale = Double.parseDouble(args[3]);
|
|
xTrans = Double.parseDouble(args[4]);
|
|
yTrans = Double.parseDouble(args[5]);
|
|
}
|
|
else if (type.equals("translate")) {
|
|
if (args.length == 1) {
|
|
xTrans = yTrans = Double.parseDouble(args[0]);
|
|
}
|
|
else {
|
|
xTrans = Double.parseDouble(args[0]);
|
|
yTrans = Double.parseDouble(args[1]);
|
|
}
|
|
}
|
|
else if (type.equals("scale")) {
|
|
if (args.length == 1) {
|
|
xScale = yScale = Double.parseDouble(args[0]);
|
|
}
|
|
else {
|
|
xScale = Double.parseDouble(args[0]);
|
|
yScale = Double.parseDouble(args[1]);
|
|
}
|
|
} //I'm not worrying about shear and rotation because I don't want to
|
|
transString = transString.substring(j+1);
|
|
}
|
|
transformStack.push(new double[] {xScale, yScale, xTrans, yTrans});
|
|
}
|
|
|
|
private void parsePath(String d) throws Exception {
|
|
currentFormatString += " d=\"";
|
|
format.add(currentFormatString);
|
|
paths.add(new Path(d, transformStack.peek(), vbMinX, vbMinY, vbWidth, vbHeight));
|
|
currentFormatString = "\"";
|
|
length += paths.get(paths.size()-1).size();
|
|
}
|
|
};
|
|
|
|
parser.parse(new BufferedInputStream(new FileInputStream(file)), handler);
|
|
}
|
|
|
|
|
|
private SVGMap(List<Path> paths, List<String> format, double vbMinX, double vbMinY,
|
|
double vbWidth, double vbHeight, double svgWidth, double svgHeight, int size) {
|
|
this.paths = paths;
|
|
this.format = format;
|
|
this.vbMinX = vbMinX;
|
|
this.vbMinY = vbMinY;
|
|
this.vbWidth = vbWidth;
|
|
this.vbHeight = vbHeight;
|
|
this.svgWidth = svgWidth;
|
|
this.svgHeight = svgHeight;
|
|
this.length = size;
|
|
}
|
|
|
|
|
|
|
|
public int length() {
|
|
return this.length;
|
|
}
|
|
|
|
|
|
public int numCurves() {
|
|
return paths.size();
|
|
}
|
|
|
|
|
|
@Override
|
|
public Iterator<Path> iterator() {
|
|
return paths.iterator();
|
|
}
|
|
|
|
|
|
/**
|
|
* @return the result of replacing all instances of target in the format strings
|
|
*/
|
|
public SVGMap replace(CharSequence target, CharSequence replacement) {
|
|
List<String> newFormat = new LinkedList<String>();
|
|
for (String f: this.format)
|
|
newFormat.add(f.replace(target, replacement));
|
|
return new SVGMap(
|
|
paths, newFormat, vbMinX, vbMinY, vbWidth, vbHeight, svgWidth, svgHeight, length);
|
|
}
|
|
|
|
|
|
public void save(List<Path> paths, File file, double inMinX, double inMaxY, double inWidth,
|
|
double inHeight, DoubleConsumer tracker) throws IOException {
|
|
BufferedWriter out = new BufferedWriter(new FileWriter(file));
|
|
|
|
int i = 0;
|
|
final Iterator<String> formatIterator = format.iterator();
|
|
final Iterator<Path> curveIterator = paths.iterator();
|
|
|
|
out.write(replacePlaceholders(formatIterator.next(), inWidth/inHeight));
|
|
while (curveIterator.hasNext()) {
|
|
out.write(breakWraps(curveIterator.next()).toString(inMinX, inMaxY, vbMinX, vbMinY,
|
|
Math.max(vbWidth, vbHeight)/Math.max(inWidth, inHeight)));
|
|
out.write(formatIterator.next());
|
|
tracker.accept((double)i/paths.size());
|
|
i ++;
|
|
}
|
|
out.close();
|
|
}
|
|
|
|
|
|
private String replacePlaceholders(String str, double desiredAspectRatio) { //change the width, height, and viewBox attributes to match the new aspect ratio
|
|
double outVBWidth, outVBHeight, outSVGWidth, outSVGHeight;
|
|
if (desiredAspectRatio > 1) {
|
|
outVBWidth = Math.max(this.vbWidth, this.vbHeight);
|
|
outVBHeight = outVBWidth/desiredAspectRatio;
|
|
outSVGWidth = Math.max(this.svgWidth, this.svgHeight);
|
|
outSVGHeight = outSVGWidth/desiredAspectRatio;
|
|
}
|
|
else {
|
|
outVBHeight = Math.max(this.vbWidth, this.vbHeight);
|
|
outVBWidth = outVBHeight*desiredAspectRatio;
|
|
outSVGHeight = Math.max(this.svgWidth, this.svgHeight);
|
|
outSVGWidth = outSVGHeight*desiredAspectRatio;
|
|
}
|
|
String viewBoxString = formatDouble(vbMinX) + " " + formatDouble(vbMinY) + " "
|
|
+ formatDouble(outVBWidth) + " " + formatDouble(outVBHeight);
|
|
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("viewBox"), viewBoxString);
|
|
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("width"), formatDouble(outSVGWidth));
|
|
str = str.replace(ATTRIBUTE_PLACEHOLDERS.get("height"), formatDouble(outSVGHeight));
|
|
return str;
|
|
}
|
|
|
|
|
|
private Path breakWraps(Path continuous) { //break excessively long commands, as they are likely wrapping over a discontinuity
|
|
if (continuous.size() <= 2) return continuous;
|
|
Path broken = new Path();
|
|
double[] lens = {Double.NaN, Double.NaN, Double.NaN}; //the revolving array of command lengths
|
|
for (int i = 0; i < continuous.size(); i ++) {
|
|
if (i < continuous.size()-1 && continuous.get(i+1).type != 'M')
|
|
lens[2] = Math.hypot( //compute this next length
|
|
continuous.get(i+1).args[0] - continuous.get(i).args[0],
|
|
continuous.get(i+1).args[1] - continuous.get(i).args[1]);
|
|
else
|
|
lens[2] = Double.NaN;
|
|
|
|
char type = continuous.get(i).type;
|
|
if ((Double.isNaN(lens[0]) || lens[1] > 20*lens[0]) //and compare it to the last two lengths
|
|
&& (Double.isNaN(lens[2]) || lens[1] > 20*lens[2]))
|
|
type = 'M';
|
|
|
|
broken.add(new Command(type, continuous.get(i).args.clone()));
|
|
lens[0] = lens[1];
|
|
lens[1] = lens[2];
|
|
}
|
|
|
|
return broken;
|
|
}
|
|
|
|
|
|
private static boolean isNonELetter(char c) {
|
|
return (c >= 'A' && c <= 'Z' && c != 'E') || (c >= 'a' && c <= 'z' && c != 'e');
|
|
}
|
|
|
|
|
|
private static String formatDouble(double d) { //format numbers just the way I want them
|
|
String str = String.format("%04d", (int)Math.round(d*1000));
|
|
str = str.substring(0, str.length()-3) + "." + str.substring(str.length()-3);
|
|
return str.replaceFirst("\\.?0*$", "");
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
* An svg path String, stored in a modifiable form
|
|
* @author jkunimune
|
|
*/
|
|
public static class Path extends ArrayList<Command> {
|
|
|
|
private static final long serialVersionUID = 8635895911857570332L;
|
|
|
|
public Path() {
|
|
super();
|
|
}
|
|
|
|
public Path(Command... commands) {
|
|
this();
|
|
this.addAll(Arrays.asList(commands));
|
|
}
|
|
|
|
public Path(String d, double vbWidth, double vbHeight) throws Exception {
|
|
this(d, new double[] {1,1,0,0}, 0, 0, vbWidth, vbHeight);
|
|
}
|
|
|
|
public Path(String d, double[] transform,
|
|
double vbMinX, double vbMinY, double vbWidth, double vbHeight) throws Exception { //I don't know if this is bad coding practice, but I don't really want to find a way to gracefully catch all possible errors into some more specific Exception class
|
|
super();
|
|
|
|
int i = 0;
|
|
double[] lastMove = {0, 0}; //for closepaths
|
|
double[] last = {0, 0}; //for relative coordinates
|
|
while (i < d.length()) {
|
|
char type = d.charAt(i);
|
|
String argString = "";
|
|
while (i+1 < d.length() && !isNonELetter(d.charAt(i+1))) {
|
|
i ++;
|
|
argString += d.charAt(i);
|
|
}
|
|
argString = argString.replaceAll("([0-9\\.])-", "$1,-"); //this is necessary because some Adobe products leave out delimiters between negative numbers
|
|
i ++;
|
|
|
|
String[] argStrings;
|
|
if (argString.trim().isEmpty()) argStrings = new String[0];
|
|
else argStrings = argString.trim().split("[\\s,]+");
|
|
final double[] args;
|
|
|
|
if (type == 'a' || type == 'A') {
|
|
type += (type == 'a') ? 'l' : 'L'; //change this to a line; I don't want to deal with arcs
|
|
argStrings = new String[] {argStrings[3], argStrings[4]};
|
|
}
|
|
if (type == 'h' || type == 'H' || type == 'v' || type == 'V') { //convert these to 'L'
|
|
final int direcIdx = (type%32 == 8) ? 0 : 1;
|
|
args = new double[] {last[0], last[1]};
|
|
if (type <= 'Z') args[direcIdx] = Double.parseDouble(argStrings[0]); //uppercase (absolute)
|
|
else args[direcIdx] += Double.parseDouble(argStrings[0]); //lowercase (relative)
|
|
last[direcIdx] = args[direcIdx];
|
|
type = 'L';
|
|
}
|
|
else if (type == 'z' || type == 'Z') { //change this to 'L', too
|
|
args = new double[] {lastMove[0], lastMove[1]};
|
|
type = 'L';
|
|
}
|
|
else {
|
|
args = new double[argStrings.length];
|
|
for (int j = 0; j < args.length; j ++) {
|
|
args[j] = Double.parseDouble(argStrings[j]); //parse the coordinate
|
|
|
|
if (type >= 'a')
|
|
args[j] += last[j%2]; //account for relative commands
|
|
last[j%2] = args[j];
|
|
}
|
|
if (type >= 'a') //make all letters uppercase
|
|
type -= 32;
|
|
}
|
|
if (type == 'M') { //make note, so we can interpret closepaths properly
|
|
lastMove[0] = args[args.length-2];
|
|
lastMove[1] = args[args.length-1];
|
|
}
|
|
|
|
for (int j = 0; j < args.length; j ++) {
|
|
if (!Double.isFinite(args[j]))
|
|
throw new IllegalArgumentException("uhh... "+type+argString);
|
|
if (j%2 == 0) {
|
|
args[j] = args[j]*transform[0] + transform[2]; //apply the transformation
|
|
args[j] = Math2.linInterp(args[j], vbMinX, vbMinX+vbWidth,
|
|
-Math.PI, Math.PI); //scale to radians
|
|
}
|
|
else {
|
|
args[j] = args[j]*transform[1] + transform[3];
|
|
args[j] = Math2.linInterp(args[j], vbMinY+vbHeight, vbMinY, //keep in mind that these are paired longitude-latitude
|
|
-Math.PI/2, Math.PI/2); //not latitude-longitude, as they are elsewhere
|
|
}
|
|
}
|
|
|
|
this.add(new Command(type, args));
|
|
}
|
|
}
|
|
|
|
public Path(Command command) {
|
|
// TODO: Implement this
|
|
}
|
|
|
|
public String toString(
|
|
double inMinX, double inMaxY, double outMinX, double outMinY, double outScale) {
|
|
String s = "";
|
|
for (Command c: this)
|
|
s += c.toString(inMinX, inMaxY, outMinX, outMinY, outScale)+" ";
|
|
return s;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* An SVG command, like line or bezier curve or whatever
|
|
* @author jkunimune
|
|
*/
|
|
public static class Command {
|
|
final public char type; //M, L, C, etc. This will never be lowercase
|
|
final public double[] args; //the absolute coordinates that go with it
|
|
|
|
public Command(char type, double[] args) {
|
|
this.type = type;
|
|
this.args = args;
|
|
}
|
|
|
|
public Command(Command command) {
|
|
this(command.type, command.args.clone());
|
|
}
|
|
|
|
public String toString() {
|
|
return this.toString(-1, -1, 0, 0, 1);
|
|
}
|
|
|
|
public String toString(
|
|
double inMinX, double inMaxY, double outMinX, double outMinY, double outScale) {
|
|
String s = Character.toString(type);
|
|
for (int i = 0; i < args.length; i ++) {
|
|
if (i%2 == 0)
|
|
s += formatDouble(outMinX + (args[i]-inMinX)*outScale)+",";
|
|
else
|
|
s += formatDouble(outMinY + (inMaxY-args[i])*outScale)+",";
|
|
}
|
|
return s.substring(0, s.length()-1);
|
|
}
|
|
}
|
|
|
|
}
|