/* * Copyright 2013 Martin Ždila, Freemap Slovakia * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package app.gpx_animator; import org.jetbrains.annotations.NonNls; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import app.gpx_animator.frameWriter.FileFrameWriter; import app.gpx_animator.frameWriter.FrameWriter; import app.gpx_animator.frameWriter.VideoFrameWriter; import javax.imageio.ImageIO; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Font; import java.awt.FontMetrics; import java.awt.Graphics2D; import java.awt.RenderingHints; import java.awt.Shape; import java.awt.font.FontRenderContext; import java.awt.font.TextLayout; import java.awt.geom.AffineTransform; import java.awt.geom.Ellipse2D; import java.awt.geom.Line2D; import java.awt.geom.Point2D; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import java.text.DateFormat; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map.Entry; import java.util.ResourceBundle; import java.util.TreeMap; import static app.gpx_animator.Utils.isEqual; @SuppressWarnings("PMD.BeanMembersShouldSerialize") // This class is not serializable public final class Renderer { @NonNls private static final Logger LOGGER = LoggerFactory.getLogger(Renderer.class); private static final double MS = 1000d; private final ResourceBundle resourceBundle = Preferences.getResourceBundle(); private final java.util.Map<Integer, Long> speedValues = new HashMap<>(); private final Configuration cfg; private final List<List<TreeMap<Long, Point2D>>> timePointMapListList = new ArrayList<>(); private final DateFormat dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM); private Font font; private FontMetrics fontMetrics; private long minTime = Long.MAX_VALUE; private long maxTime = Long.MIN_VALUE; private double minX = Double.POSITIVE_INFINITY; private double maxX = Double.NEGATIVE_INFINITY; private double minY = Double.POSITIVE_INFINITY; private double maxY = Double.NEGATIVE_INFINITY; private double speedup; public Renderer(final Configuration cfg) { this.cfg = cfg; } private GpxPoint lastSpeedPoint = null; private long calculateSpeedForDisplay(final GpxPoint point, final int frame) { final long speed = calculateSpeed(point, getTime(frame)); speedValues.put(frame, speed); final long deleteBefore = frame - (Math.round(cfg.getFps())); // 1 second speedValues.keySet().removeIf((f) -> f < deleteBefore); return Math.round(speedValues.values().stream().mapToLong(Long::longValue).average().orElse(0)); } private long calculateSpeed(final GpxPoint point, final long time) { final long timeout = time - 1_000 * 60; // 1 minute final long distance = calculateDistance(lastSpeedPoint, point); final double timeDiff = lastSpeedPoint == null ? 0 : point.getTime() - lastSpeedPoint.getTime(); final long speed; if (distance > 0 && point.getTime() > timeout) { speed = Math.round((3_600 * distance) / timeDiff); } else { speed = 0; } lastSpeedPoint = point; return speed; } private static long calculateDistance(final GpxPoint point1, final GpxPoint point2) { if (point1 == null) { return 0; } final double lat1 = point1.getLatLon().getLat(); final double lon1 = point1.getLatLon().getLon(); final double lat2 = point2.getLatLon().getLat(); final double lon2 = point2.getLatLon().getLon(); if ((lat1 == lat2) && (lon1 == lon2)) { return 0; } else { final double theta = lon1 - lon2; final double dist = Math.sin(Math.toRadians(lat1)) * Math.sin(Math.toRadians(lat2)) + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.cos(Math.toRadians(theta)); final double arcCosine = Math.acos(dist); final double degrees = Math.toDegrees(arcCosine); final double mi = degrees * 60 * 1.1515; // to miles final double km = mi * 1.609344; // to kilometers final double m = km * 1_000; // to meters return Math.round(m); // round to full meters } } private static double lonToX(final Double maxLon) { return Math.toRadians(maxLon); } private static double latToY(final double lat) { return Math.log(Math.tan(Math.PI / 4 + Math.toRadians(lat) / 2)); } private static Color blendTailColor(final Color tailColor, final Color trackColor, final float ratio) { double r = ((double) (1 - ratio)) * tailColor.getRed() + (double) ratio * trackColor.getRed(); double g = ((double) (1 - ratio)) * tailColor.getGreen() + (double) ratio * trackColor.getGreen(); double b = ((double) (1 - ratio)) * tailColor.getBlue() + (double) ratio * trackColor.getBlue(); double a = Math.max(tailColor.getAlpha(), trackColor.getAlpha()); return new Color((int) r, (int) g, (int) b, (int) a); } public void render(final RenderingContext rc) throws UserException { final List<Long[]> spanList = new ArrayList<>(); final TreeMap<Long, Point2D> wpMap = new TreeMap<>(); parseGPX(spanList, wpMap); final boolean userSpecifiedWidth = cfg.getWidth() != null; final int width = userSpecifiedWidth ? cfg.getWidth() : 800; final Integer zoom = calculateZoomFactor(rc, width); final double scale = calculateScaleFactor(width, zoom); minX -= cfg.getMargin() / scale; maxX += cfg.getMargin() / scale; minY -= cfg.getMargin() / scale; maxY += cfg.getMargin() / scale; if (userSpecifiedWidth) { final double ww = width - (maxX - minX) * scale; minX -= ww / scale / 2.0; maxX += ww / scale / 2.0; } if (cfg.getHeight() != null) { final double hh = cfg.getHeight() - (maxY - minY) * scale; minY -= hh / scale / 2.0; maxY += hh / scale / 2.0; } timePointMapListList.forEach((timePointMapList) -> timePointMapList .forEach((timePointMap) -> translateCoordinatesToZeroZero(scale, timePointMap))); translateCoordinatesToZeroZero(scale, wpMap); final String frameFilePattern = cfg.getOutput().toString(); //noinspection MagicCharacter final int dot = frameFilePattern.lastIndexOf('.'); final String ext = dot == -1 ? null : frameFilePattern.substring(dot + 1).toLowerCase(Locale.getDefault()); final boolean toImages = ext != null && (isEqual("png", ext) || isEqual("jpg", ext)); //NON-NLS final int realWidth = calculateRealWidth(userSpecifiedWidth, scale, toImages); final int realHeight = calculateRealHeight(scale, toImages); LOGGER.info("{}x{};{}", realWidth, realHeight, scale); final FrameWriter frameWriter = toImages ? new FileFrameWriter(frameFilePattern, ext, cfg.getFps()) : new VideoFrameWriter(cfg.getOutput(), cfg.getFps(), realWidth, realHeight); final BufferedImage bi = new BufferedImage(realWidth, realHeight, BufferedImage.TYPE_3BYTE_BGR); final Graphics2D ga = (Graphics2D) bi.getGraphics(); drawBackground(rc, zoom, bi, ga); if (cfg.getFontSize() > 0) { font = new Font(Font.MONOSPACED, Font.PLAIN, cfg.getFontSize()); // TODO https://github.com/zdila/gpx-animator/issues/154 fontMetrics = ga.getFontMetrics(font); } speedup = cfg.getTotalTime() == null ? cfg.getSpeedup() : 1.0 * (maxTime - minTime) / cfg.getTotalTime(); final int frames = (int) ((maxTime + cfg.getTailDuration() - minTime) * cfg.getFps() / (MS * speedup)); final Photos photos = new Photos(cfg.getPhotoDirectory()); float skip = -1f; for (int frame = 1; frame < frames; frame++) { if (rc.isCancelled1()) { return; } final Long time = getTime(frame); skip: if (cfg.isSkipIdle()) { for (final Long[] span : spanList) { if (span[0] <= time && span[1] >= time) { break skip; } } rc.setProgress1((int) (100.0 * frame / frames), String.format(resourceBundle.getString("renderer.progress.unusedframes"), frame, (frames - 1))); skip = 1f; continue; } final int pct = (int) (100.0 * frame / frames); rc.setProgress1(pct, String.format(resourceBundle.getString("renderer.progress.frame"), frame, (frames - 1))); paint(bi, frame, 0, null); final BufferedImage bi2 = Utils.deepCopy(bi); paint(bi2, frame, cfg.getTailDuration(), cfg.getTailColor()); drawWaypoints(bi2, frame, wpMap); final Point2D marker = drawMarker(bi2, frame); if (font != null) { drawInfo(bi2, frame, marker); drawAttribution(bi2, cfg.getAttribution()); } skip = renderFlashback(skip, bi2); frameWriter.addFrame(bi2); photos.render(time, cfg, bi2, frameWriter, rc, pct); } keepLastFrame(rc, frameWriter, bi, frames); frameWriter.close(); LOGGER.info("Done."); } private float renderFlashback(final float skip, final BufferedImage bi2) { final Color flashbackColor = cfg.getFlashbackColor(); if (skip > 0f && flashbackColor.getAlpha() > 0 && cfg.getFlashbackDuration() != null && cfg.getFlashbackDuration() > 0) { final Graphics2D g2 = (Graphics2D) bi2.getGraphics(); g2.setColor(new Color(flashbackColor.getRed(), flashbackColor.getGreen(), flashbackColor.getBlue(), (int) (flashbackColor.getAlpha() * skip))); g2.fillRect(0, 0, bi2.getWidth(), bi2.getHeight()); return (float) (skip - (1000f / cfg.getFlashbackDuration() / cfg.getFps())); } return skip; } private void drawBackground(final RenderingContext rc, final Integer zoom, final BufferedImage bi, final Graphics2D ga) throws UserException { if (cfg.getTmsUrlTemplate() == null) { final Color backgroundColor = cfg.getBackgroundColor(); ga.setColor(backgroundColor); ga.fillRect(0, 0, bi.getWidth(), bi.getHeight()); } else { Map.drawMap(bi, cfg.getTmsUrlTemplate(), cfg.getBackgroundMapVisibility(), zoom, minX, maxX, minY, maxY, rc); } drawLogo(bi); } private void drawLogo(final BufferedImage bi) throws UserException { final File logo = cfg.getLogo(); if (logo != null && logo.exists()) { final BufferedImage image; try { image = ImageIO.read(logo); } catch (final IOException e) { throw new UserException("Can't read logo: ".concat(e.getMessage())); } final Graphics2D g2 = getGraphics(bi); g2.drawImage(image, cfg.getMargin(), cfg.getMargin(), image.getWidth(), image.getHeight(), null); } } private void parseGPX(final List<Long[]> spanList, final TreeMap<Long, Point2D> wpMap) throws UserException { int trackIndex = -1; for (final TrackConfiguration trackConfiguration : cfg.getTrackConfigurationList()) { trackIndex++; final GpxContentHandler gch = new GpxContentHandler(); GpxParser.parseGpx(trackConfiguration.getInputGpx(), gch); final List<TreeMap<Long, Point2D>> timePointMapList = new ArrayList<>(); for (final List<LatLon> latLonList : gch.getPointLists()) { final TreeMap<Long, Point2D> timePointMap = new TreeMap<>(); toTimePointMap(timePointMap, trackIndex, latLonList); trimGpxData(timePointMap, trackConfiguration); timePointMapList.add(timePointMap); toTimePointMap(wpMap, trackIndex, gch.getWaypointList()); mergeConnectedSpans(spanList, timePointMap); } Collections.reverse(timePointMapList); // reversing because of last known location drawing timePointMapListList.add(timePointMapList); } } private int calculateRealHeight(final double scale, final boolean toImages) { int realHeight = (int) Math.round(((maxY - minY) * scale)); if (realHeight % 2 != 0 && cfg.getHeight() == null && !toImages) { realHeight++; } return realHeight; } private int calculateRealWidth(final boolean userSpecifiedWidth, final double scale, final boolean toImages) { int realWidth = (int) Math.round(((maxX - minX) * scale)); if (realWidth % 2 != 0 && !userSpecifiedWidth && !toImages) { realWidth++; } return realWidth; } private void translateCoordinatesToZeroZero(final double scale, final TreeMap<Long, Point2D> timePointMap) { if (!timePointMap.isEmpty()) { maxTime = Math.max(maxTime, timePointMap.lastKey()); minTime = Math.min(minTime, timePointMap.firstKey()); for (final Point2D point : timePointMap.values()) { point.setLocation((point.getX() - minX) * scale, (maxY - point.getY()) * scale); } } } private void mergeConnectedSpans(final List<Long[]> spanList, final TreeMap<Long, Point2D> timePointMap) { long t0 = timePointMap.firstKey(); long t1 = timePointMap.lastKey() + cfg.getTailDuration(); for (final Iterator<Long[]> iter = spanList.iterator(); iter.hasNext();) { final Long[] span = iter.next(); if (t0 > span[0] && t1 < span[1]) { // swallowed return; } if (t0 < span[0] && t1 > span[1]) { // swallows iter.remove(); } else if (t1 > span[0] && t1 < span[1]) { t1 = span[1]; iter.remove(); } else if (t0 < span[1] && t0 > span[0]) { t0 = span[0]; iter.remove(); } } spanList.add(new Long[]{t0, t1}); } private Integer calculateZoomFactor(final RenderingContext rc, final int width) { final Integer zoom; if (cfg.getTmsUrlTemplate() != null && cfg.getZoom() == null) { // force using computed zoom final boolean userSpecifiedHeight = cfg.getHeight() != null; if (userSpecifiedHeight) { final int height = cfg.getHeight(); final int zoom1 = (int) Math.floor(Math.log(Math.PI / 128.0 * (width - cfg.getMargin() * 2) / (maxX - minX)) / Math.log(2)); final int zoom2 = (int) Math.floor(Math.log(Math.PI / 128.0 * (height - cfg.getMargin() * 2) / (maxY - minY)) / Math.log(2)); zoom = Math.min(zoom1, zoom2); } else { zoom = (int) Math.floor(Math.log(Math.PI / 128.0 * (width - cfg.getMargin() * 2) / (maxX - minX)) / Math.log(2)); } rc.setProgress1(0, String.format(resourceBundle.getString("renderer.progress.zoom"), zoom)); } else { zoom = cfg.getZoom(); } return zoom; } private double calculateScaleFactor(final int width, final Integer zoom) { return zoom == null ? (width - cfg.getMargin() * 2) / (maxX - minX) : (128.0 * (1 << zoom)) / Math.PI; } private void trimGpxData(final TreeMap<Long, Point2D> timePointMap, final TrackConfiguration trackConfiguration) { final Long trimGpxStart = trackConfiguration.getTrimGpxStart(); if (trimGpxStart != null && trimGpxStart > 0 && timePointMap.size() > 0) { final Long skipToTime = timePointMap.firstKey() + trimGpxStart; timePointMap.entrySet().removeIf(e -> e.getKey() < skipToTime); } final Long trimGpxEnd = trackConfiguration.getTrimGpxEnd(); if (trimGpxEnd != null && trimGpxEnd > 0 && timePointMap.size() > 0) { final Long skipAfterTime = timePointMap.lastKey() - trimGpxEnd; timePointMap.entrySet().removeIf(e -> e.getKey() > skipAfterTime); } } private void keepLastFrame(final RenderingContext rc, final FrameWriter frameWriter, final BufferedImage bi, final int frames) throws UserException { final boolean keepLastFrame = cfg.getKeepLastFrame() != null && cfg.getKeepLastFrame() > 0; if (keepLastFrame) { final Point2D marker = drawMarker(bi, frames); if (font != null) { drawInfo(bi, frames, marker); drawAttribution(bi, cfg.getAttribution()); } final long ms = cfg.getKeepLastFrame(); final long fps = Double.valueOf(cfg.getFps()).longValue(); final long stillFrames = ms / 1_000 * fps; for (long stillFrame = 0; stillFrame < stillFrames; stillFrame++) { final int pct = (int) (100.0 * stillFrame / stillFrames); rc.setProgress1(pct, String.format(resourceBundle.getString("renderer.progress.keeplastframe"), stillFrame, stillFrames)); frameWriter.addFrame(bi); if (rc.isCancelled1()) { return; } } } } private void drawWaypoints(final BufferedImage bi, final int frame, final TreeMap<Long, Point2D> wpMap) { final Double waypointSize = cfg.getWaypointSize(); if (waypointSize == null || waypointSize == 0.0 || wpMap.isEmpty()) { return; } final Graphics2D g2 = getGraphics(bi); final long t2 = getTime(frame); if (t2 >= wpMap.firstKey()) { for (final Point2D p : wpMap.subMap(wpMap.firstKey(), t2).values()) { g2.setColor(Color.white); final Ellipse2D.Double marker = createMarker(waypointSize, p); g2.setStroke(new BasicStroke(1f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); g2.fill(marker); g2.setColor(Color.black); g2.draw(marker); printText(g2, ((NamedPoint) p).getName(), (float) p.getX() + 8f, (float) p.getY() + 4f); } } } private Ellipse2D.Double createMarker(final Double size, final Point2D point) { return new Ellipse2D.Double(point.getX() - size / 2.0, point.getY() - size / 2.0, size, size); } private void toTimePointMap(final TreeMap<Long, Point2D> timePointMap, final int trackIndex, final List<LatLon> latLonList) throws UserException { long forcedTime = 0; final TrackConfiguration trackConfiguration = cfg.getTrackConfigurationList().get(trackIndex); final Double minLon = cfg.getMinLon(); final Double maxLon = cfg.getMaxLon(); final Double minLat = cfg.getMinLat(); final Double maxLat = cfg.getMaxLat(); if (minLon != null) { minX = lonToX(minLon); } if (maxLon != null) { maxX = lonToX(maxLon); } if (maxLat != null) { minY = latToY(maxLat); } if (minLat != null) { maxY = latToY(minLat); } for (final LatLon latLon : latLonList) { final double x = lonToX(latLon.getLon()); final double y = latToY(latLon.getLat()); if (minLon == null) { minX = Math.min(x, minX); } if (maxLat == null) { minY = Math.min(y, minY); } if (maxLon == null) { maxX = Math.max(x, maxX); } if (minLat == null) { maxY = Math.max(y, maxY); } long time; final Long forcedPointInterval = trackConfiguration.getForcedPointInterval(); if (forcedPointInterval != null) { forcedTime += forcedPointInterval; time = forcedTime; } else { time = latLon.getTime(); if (time == Long.MIN_VALUE) { throw new UserException("missing time for point; specify --forced-point-time-interval option"); } } if (trackConfiguration.getTimeOffset() != null) { time += trackConfiguration.getTimeOffset(); } final Point2D point; if (latLon instanceof Waypoint) { final NamedPoint namedPoint = new NamedPoint(); namedPoint.setLocation(x, y); namedPoint.setName(((Waypoint) latLon).getName()); point = namedPoint; } else { point = new GpxPoint(x, y, latLon, time); } // hack to prevent overwriting existing (way)point with same time long freeTime = time; while (timePointMap.containsKey(freeTime)) { freeTime++; } timePointMap.put(freeTime, point); } } private void drawInfo(final BufferedImage bi, final int frame, final Point2D marker) { final String dateString = dateFormat.format(getTime(frame)); final String latLongString = getLatLonString(marker); final String speedString = getSpeedString(marker, frame); final Graphics2D graphics = getGraphics(bi); printText(graphics, dateString, bi.getWidth() - fontMetrics.stringWidth(dateString) - cfg.getMargin(), bi.getHeight() - cfg.getMargin()); printText(graphics, latLongString, bi.getWidth() - fontMetrics.stringWidth(latLongString) - cfg.getMargin(), bi.getHeight() - cfg.getMargin() - fontMetrics.getHeight()); printText(graphics, speedString, bi.getWidth() - fontMetrics.stringWidth(speedString) - cfg.getMargin(), bi.getHeight() - cfg.getMargin() - fontMetrics.getHeight() * 2); } private String getSpeedString(final Point2D point, final int frame) { if (point instanceof GpxPoint) { final GpxPoint gpxPoint = (GpxPoint) point; final long speed = calculateSpeedForDisplay(gpxPoint, frame); return String.format("%d km/h", speed); //NON-NLS } else { return ""; } } private String getLatLonString(final Point2D point) { if (point instanceof GpxPoint) { final GpxPoint gpxPoint = (GpxPoint) point; final LatLon latLon = gpxPoint.getLatLon(); return String.format("%.4f, %.4f", latLon.getLat(), latLon.getLon()); //NON-NLS } else { return ""; } } private void drawAttribution(final BufferedImage bi, final String attribution) { printText(getGraphics(bi), attribution, cfg.getMargin(), bi.getHeight() - cfg.getMargin()); } private Point2D drawMarker(final BufferedImage bi, final int frame) { if (cfg.getMarkerSize() == null || cfg.getMarkerSize() == 0.0) { return null; } Point2D point = null; final Graphics2D g2 = getGraphics(bi); final long t2 = getTime(frame); final List<TrackConfiguration> trackConfigurationList = cfg.getTrackConfigurationList(); int i = 0; outer: for (final List<TreeMap<Long, Point2D>> timePointMapList : timePointMapListList) { final TrackConfiguration trackConfiguration = trackConfigurationList.get(i++); for (final TreeMap<Long, Point2D> timePointMap : timePointMapList) { final Entry<Long, Point2D> ceilingEntry = timePointMap.ceilingEntry(t2); final Entry<Long, Point2D> floorEntry = timePointMap.floorEntry(t2); if (floorEntry == null) { continue; } point = floorEntry.getValue(); if (t2 - floorEntry.getKey() <= cfg.getTailDuration()) { g2.setColor(ceilingEntry == null ? Color.white : trackConfiguration.getColor()); final TrackIcon trackIcon = trackConfiguration.getTrackIcon(); if (trackIcon == null || trackIcon.getKey().isEmpty()) { drawSimpleCircleOnGraphics2D(point, g2); } else { try { drawIconOnGraphics2D(point, g2, trackIcon); } catch (final IOException e) { drawSimpleCircleOnGraphics2D(point, g2); } } final String label = trackConfiguration.getLabel(); if (!label.isEmpty()) { printText(g2, label, (float) point.getX() + 8f, (float) point.getY() + 4f); } } continue outer; // NOPMD -- Continue the outer loop, not the inner one } } return point; } private void drawSimpleCircleOnGraphics2D(final Point2D point, final Graphics2D g2) { final double markerSize = cfg.getMarkerSize(); final Ellipse2D.Double marker = createMarker(markerSize, point); g2.setStroke(new BasicStroke(1f)); g2.fill(marker); g2.setColor(Color.black); g2.draw(marker); } private void drawIconOnGraphics2D(final Point2D point, final Graphics2D g2, final TrackIcon trackIcon) throws IOException { final BufferedImage icon = ImageIO.read(getClass().getResource(trackIcon.getFilename())); final AffineTransform at = new AffineTransform(); at.translate((int) point.getX() + 8f, (int) point.getY() + 4f); at.translate(-icon.getWidth() / 2d, -icon.getHeight() / 2d); g2.drawImage(icon, at, null); } private void paint(final BufferedImage bi, final int frame, final long backTime, final Color overrideColor) { final Graphics2D g2 = getGraphics(bi); final long time = getTime(frame); final List<TrackConfiguration> trackConfigurationList = cfg.getTrackConfigurationList(); int i = 0; for (final List<TreeMap<Long, Point2D>> timePointMapList : timePointMapListList) { final TrackConfiguration trackConfiguration = trackConfigurationList.get(i++); for (final TreeMap<Long, Point2D> timePointMap : timePointMapList) { g2.setStroke(new BasicStroke(trackConfiguration.getLineWidth(), BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); final Long toTime = timePointMap.floorKey(time); if (toTime == null) { continue; } Point2D prevPoint = null; if (backTime == 0) { final long prevTime = getTime(frame - 1); Long fromTime = timePointMap.floorKey(prevTime); if (fromTime == null) { // try ceiling because we may be at beginning fromTime = timePointMap.ceilingKey(prevTime); } if (fromTime == null) { continue; } g2.setPaint(trackConfiguration.getColor()); for (final Entry<Long, Point2D> entry : timePointMap.subMap(fromTime, true, toTime, true).entrySet()) { if (prevPoint != null) { g2.draw(new Line2D.Double(prevPoint, entry.getValue())); } prevPoint = entry.getValue(); } } else { for (final Entry<Long, Point2D> entry : timePointMap.subMap(toTime - backTime, true, toTime, true).entrySet()) { if (prevPoint != null) { final float ratio = (backTime - time + entry.getKey()) * 1f / backTime; if (ratio > 0) { g2.setPaint(blendTailColor(trackConfiguration.getColor(), overrideColor, ratio)); g2.draw(new Line2D.Double(prevPoint, entry.getValue())); } } prevPoint = entry.getValue(); } } } } } private long getTime(final int frame) { return (long) Math.floor(minTime + frame / cfg.getFps() * MS * speedup); } private void printText(final Graphics2D g2, final String text, final float x, final float y) { final FontRenderContext frc = g2.getFontRenderContext(); g2.setStroke(new BasicStroke(3f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); final int height = g2.getFontMetrics(font).getHeight(); final String[] lines = text == null ? new String[0] : text.split("\n"); float yy = y - (lines.length - 1) * height; for (final String line : lines) { if (!line.isEmpty()) { final TextLayout tl = new TextLayout(line, font, frc); final Shape sha = tl.getOutline(AffineTransform.getTranslateInstance(x, yy)); g2.setColor(Color.white); g2.fill(sha); g2.draw(sha); g2.setFont(font); g2.setColor(Color.black); g2.drawString(line, x, yy); } yy += height; } } private Graphics2D getGraphics(final BufferedImage bi) { final Graphics2D g2 = (Graphics2D) bi.getGraphics(); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); g2.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON); g2.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); g2.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); g2.setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY); return g2; } private static class NamedPoint extends Point2D.Double { private static final long serialVersionUID = 4011941819652468006L; private String name; public String getName() { return name; } public void setName(final String name) { this.name = name; } } }