Ignore:
Timestamp:
2017-03-20T17:47:47+01:00 (7 years ago)
Author:
michael2402
Message:

Move label / icon placement code to new package. Unify the handling of text label positioning (path / center)

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledMapRenderer.java

    r11746 r11748  
    2929import java.util.Collection;
    3030import java.util.Collections;
    31 import java.util.Comparator;
    3231import java.util.HashMap;
    3332import java.util.Iterator;
     
    4140import java.util.function.Consumer;
    4241import java.util.function.Supplier;
    43 import java.util.stream.Collectors;
    4442
    4543import javax.swing.AbstractButton;
     
    6664import org.openstreetmap.josm.gui.NavigatableComponent;
    6765import org.openstreetmap.josm.gui.draw.MapViewPath;
     66import org.openstreetmap.josm.gui.draw.MapViewPositionAndRotation;
    6867import org.openstreetmap.josm.gui.mappaint.ElemStyles;
    6968import org.openstreetmap.josm.gui.mappaint.MapPaintStyles;
     
    7675import org.openstreetmap.josm.gui.mappaint.styleelement.MapImage;
    7776import org.openstreetmap.josm.gui.mappaint.styleelement.NodeElement;
    78 import org.openstreetmap.josm.gui.mappaint.styleelement.PositionForAreaStrategy;
    7977import org.openstreetmap.josm.gui.mappaint.styleelement.RepeatImageElement.LineImageAlignment;
    8078import org.openstreetmap.josm.gui.mappaint.styleelement.StyleElement;
     
    8280import org.openstreetmap.josm.gui.mappaint.styleelement.TextElement;
    8381import org.openstreetmap.josm.gui.mappaint.styleelement.TextLabel;
     82import org.openstreetmap.josm.gui.mappaint.styleelement.placement.PositionForAreaStrategy;
    8483import org.openstreetmap.josm.tools.CompositeList;
    8584import org.openstreetmap.josm.tools.Geometry;
     
    340339
    341340    /**
    342      * Displays text at specified position including its halo, if applicable.
    343      *
    344      * @param gv Text's glyphs to display. If {@code null}, use text from {@code s} instead.
    345      * @param s text to display if {@code gv} is {@code null}
    346      * @param x X position
    347      * @param y Y position
    348      * @param disabled {@code true} if element is disabled (filtered out)
    349      * @param text text style to use
    350      */
    351     private void displayText(GlyphVector gv, String s, int x, int y, boolean disabled, TextLabel text) {
    352         if (gv == null && s.isEmpty()) return;
    353         if (isInactiveMode || disabled) {
    354             g.setColor(inactiveColor);
    355             if (gv != null) {
    356                 g.drawGlyphVector(gv, x, y);
    357             } else {
    358                 g.setFont(text.font);
    359                 g.drawString(s, x, y);
    360             }
    361         } else if (text.haloRadius != null) {
    362             g.setStroke(new BasicStroke(2*text.haloRadius, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND));
    363             g.setColor(text.haloColor);
    364             Shape textOutline;
    365             if (gv == null) {
    366                 FontRenderContext frc = g.getFontRenderContext();
    367                 TextLayout tl = new TextLayout(s, text.font, frc);
    368                 textOutline = tl.getOutline(AffineTransform.getTranslateInstance(x, y));
    369             } else {
    370                 textOutline = gv.getOutline(x, y);
    371             }
    372             g.draw(textOutline);
    373             g.setStroke(new BasicStroke());
    374             g.setColor(text.color);
    375             g.fill(textOutline);
    376         } else {
    377             g.setColor(text.color);
    378             if (gv != null) {
    379                 g.drawGlyphVector(gv, x, y);
    380             } else {
    381                 g.setFont(text.font);
    382                 g.drawString(s, x, y);
    383             }
    384         }
    385     }
    386 
    387     /**
    388341     * Worker function for drawing areas.
    389342     *
     
    400353     * polygons)
    401354     * @param disabled If this should be drawn with a special disabled style.
    402      * @param text The text to write on the area.
    403      */
    404     protected void drawArea(OsmPrimitive osm, Path2D.Double path, Color color,
     355     * @param text Ignored. Use {@link #drawText(OsmPrimitive, TextLabel)} instead.
     356     */
     357    protected void drawArea(OsmPrimitive osm, MapViewPath path, Color color,
    405358            MapImage fillImage, Float extent, Path2D.Double pfClip, boolean disabled, TextLabel text) {
    406359        if (!isOutlineOnly && color.getAlpha() != 0) {
    407             Shape area = path.createTransformedShape(mapState.getAffineTransform());
     360            Shape area = path;
    408361            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_OFF);
    409362            if (fillImage == null) {
     
    450403            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, antialiasing);
    451404        }
    452 
    453         drawAreaText(osm, text, path);
    454     }
    455 
    456     private void drawAreaText(OsmPrimitive osm, TextLabel text, Path2D.Double path) {
    457         if (text != null && isShowNames() && isAreaVisible(path)) {
    458             // abort if we can't compose the label to be rendered
    459             if (text.labelCompositionStrategy == null) return;
    460             String name = text.labelCompositionStrategy.compose(osm);
    461             if (name == null || name.isEmpty()) return;
    462 
    463             Shape area = path.createTransformedShape(mapState.getAffineTransform());
    464             FontMetrics fontMetrics = g.getFontMetrics(orderFont); // if slow, use cache
    465             Rectangle2D nb = fontMetrics.getStringBounds(name, g); // if slow, approximate by strlen()*maxcharbounds(font)
    466 
    467             Rectangle2D centeredNBounds = text.getLabelPositionStrategy().findLabelPlacement(area, nb);
    468             if (centeredNBounds != null) {
    469                 Font defaultFont = g.getFont();
    470                 int x = (int) (centeredNBounds.getMinX() - nb.getMinX());
    471                 int y = (int) (centeredNBounds.getMinY() - nb.getMinY());
    472                 displayText(null, name, x, y, osm.isDisabled(), text);
    473                 g.setFont(defaultFont);
    474             } else if (Main.isTraceEnabled()) {
    475                 Main.trace("Couldn't find a correct label placement for "+osm+" / "+name);
    476             }
    477         }
    478405    }
    479406
     
    495422        if (!r.isDisabled() && !multipolygon.getOuterWays().isEmpty()) {
    496423            for (PolyData pd : multipolygon.getCombinedPolygons()) {
    497                 Path2D.Double p = pd.get();
     424                MapViewPath p = new MapViewPath(mapState);
     425                p.appendFromEastNorth(pd.get());
     426                p.setWindingRule(Path2D.WIND_EVEN_ODD);
    498427                Path2D.Double pfClip = null;
    499428                if (!isAreaVisible(p)) {
     
    574503        g.setFont(text.font);
    575504
    576         int x = (int) (Math.round(p.getInViewX()) + text.xOffset);
    577         int y = (int) (Math.round(p.getInViewY()) + text.yOffset);
     505        FontRenderContext frc = g.getFontRenderContext();
     506        Rectangle2D bounds = text.font.getStringBounds(s, frc);
     507
     508        double x = Math.round(p.getInViewX()) + text.xOffset + bounds.getCenterX();
     509        double y = Math.round(p.getInViewY()) + text.yOffset + bounds.getCenterY();
    578510        /**
    579511         *
     
    591523            x += box.x + box.width + 2;
    592524        } else {
    593             FontRenderContext frc = g.getFontRenderContext();
    594             Rectangle2D bounds = text.font.getStringBounds(s, frc);
    595525            int textWidth = (int) bounds.getWidth();
    596526            if (bs.hAlign == HorizontalTextAlignment.CENTER) {
     
    604534            y += box.y + box.height;
    605535        } else {
    606             FontRenderContext frc = g.getFontRenderContext();
    607536            LineMetrics metrics = text.font.getLineMetrics(s, frc);
    608537            if (bs.vAlign == VerticalTextAlignment.ABOVE) {
     
    616545            } else throw new AssertionError();
    617546        }
    618         displayText(null, s, x, y, n.isDisabled(), text);
     547
     548        displayText(n, text, s, bounds, new MapViewPositionAndRotation(mapState.getForView(x, y), 0));
    619549        g.setFont(defaultFont);
    620550    }
     
    757687
    758688        forEachPolygon(osm, path -> {
    759             Shape area = path.createTransformedShape(mapState.getAffineTransform());
    760             Rectangle2D placement = iconPosition.findLabelPlacement(area, iconRect);
     689            MapViewPositionAndRotation placement = iconPosition.findLabelPlacement(path, iconRect);
    761690            if (placement == null) {
    762691                return;
    763692            }
    764             MapViewPoint p = mapState.getForView(placement.getCenterX(), placement.getCenterY());
    765             drawIcon(p, img, disabled, selected, member, theta, (g, r) -> {
     693            MapViewPoint p = placement.getPoint();
     694            drawIcon(p, img, disabled, selected, member, theta + placement.getRotation(), (g, r) -> {
    766695                if (useStrokes) {
    767696                    g.setStroke(new BasicStroke(2));
     
    10711000
    10721001    /**
    1073      * A half segment that can be used to place text on it. Used in the drawTextOnPath algorithm.
    1074      * @author Michael Zangl
    1075      */
    1076     private static class HalfSegment {
    1077         /**
    1078          * start point of half segment (as length along the way)
    1079          */
    1080         final double start;
    1081         /**
    1082          * end point of half segment (as length along the way)
    1083          */
    1084         final double end;
    1085         /**
    1086          * quality factor (off screen / partly on screen / fully on screen)
    1087          */
    1088         final double quality;
    1089 
    1090         /**
    1091          * Create a new half segment
    1092          * @param start The start along the way
    1093          * @param end The end of the segment
    1094          * @param quality A quality factor.
    1095          */
    1096         HalfSegment(double start, double end, double quality) {
    1097             super();
    1098             this.start = start;
    1099             this.end = end;
    1100             this.quality = quality;
    1101         }
    1102 
    1103         @Override
    1104         public String toString() {
    1105             return "HalfSegment [start=" + start + ", end=" + end + ", quality=" + quality + "]";
    1106         }
    1107     }
    1108 
    1109     /**
    11101002     * Draws a text for the given primitive
    11111003     * @param osm The primitive to draw the text for
     
    11141006     */
    11151007    public void drawText(OsmPrimitive osm, TextLabel text) {
    1116         PositionForAreaStrategy position = text.getLabelPositionStrategy();
    1117         if (position.supportsGlyphVector()) {
    1118             if (osm instanceof Way) {
    1119                 // we might allow this for the outline of relations as well.
    1120                 drawTextOnPath((Way) osm, text);
    1121             }
     1008        if (!isShowNames()) {
     1009            return;
     1010        }
     1011        String name = text.getString(osm);
     1012        if (name == null || name.isEmpty()) {
     1013            return;
     1014        }
     1015
     1016        FontMetrics fontMetrics = g.getFontMetrics(text.font); // if slow, use cache
     1017        Rectangle2D nb = fontMetrics.getStringBounds(name, g); // if slow, approximate by strlen()*maxcharbounds(font)
     1018
     1019        Font defaultFont = g.getFont();
     1020        forEachPolygon(osm, path -> {
     1021            //TODO: Ignore areas that are out of bounds.
     1022            PositionForAreaStrategy position = text.getLabelPositionStrategy();
     1023            MapViewPositionAndRotation center = text.getLabelPositionStrategy().findLabelPlacement(path, nb);
     1024            if (center != null) {
     1025                displayText(osm, text, name, nb, center);
     1026            } else if (position.supportsGlyphVector()) {
     1027                List<GlyphVector> gvs = Utils.getGlyphVectorsBidi(name, text.font, g.getFontRenderContext());
     1028
     1029                List<GlyphVector> translatedGvs = position.generateGlyphVectors(path, nb, gvs, isGlyphVectorDoubleTranslationBug(text.font));
     1030                displayText(() -> translatedGvs.forEach(gv -> g.drawGlyphVector(gv, 0, 0)),
     1031                        () -> translatedGvs.stream().collect(
     1032                                        () -> new Path2D.Double(),
     1033                                        (p, gv) -> p.append(gv.getOutline(0, 0), false),
     1034                                        (p1, p2) -> p1.append(p2, false)),
     1035                        osm.isDisabled(), text);
     1036            } else if (Main.isTraceEnabled()) {
     1037                Main.trace("Couldn't find a correct label placement for " + osm + " / " + name);
     1038            }
     1039        });
     1040        g.setFont(defaultFont);
     1041    }
     1042
     1043    private void displayText(OsmPrimitive osm, TextLabel text, String name, Rectangle2D nb,
     1044            MapViewPositionAndRotation center) {
     1045        AffineTransform at = AffineTransform.getTranslateInstance(center.getPoint().getInViewX(), center.getPoint().getInViewY());
     1046        at.rotate(center.getRotation());
     1047        at.translate(-nb.getCenterX(), -nb.getCenterY());
     1048        displayText(() -> {
     1049            AffineTransform defaultTransform = g.getTransform();
     1050            g.setTransform(at);
     1051            g.setFont(text.font);
     1052            g.drawString(name, 0, 0);
     1053            g.setTransform(defaultTransform);
     1054        }, () -> {
     1055            FontRenderContext frc = g.getFontRenderContext();
     1056            TextLayout tl = new TextLayout(name, text.font, frc);
     1057            return tl.getOutline(at);
     1058        }, osm.isDisabled(), text);
     1059    }
     1060
     1061    /**
     1062     * Displays text at specified position including its halo, if applicable.
     1063     *
     1064     * @param fill The function that fills the text
     1065     * @param outline The function to draw the outline
     1066     * @param disabled {@code true} if element is disabled (filtered out)
     1067     * @param text text style to use
     1068     */
     1069    private void displayText(Runnable fill, Supplier<Shape> outline, boolean disabled, TextLabel text) {
     1070        if (isInactiveMode || disabled) {
     1071            g.setColor(inactiveColor);
     1072            fill.run();
     1073        } else if (text.haloRadius != null) {
     1074            g.setStroke(new BasicStroke(2*text.haloRadius, BasicStroke.CAP_BUTT, BasicStroke.JOIN_ROUND));
     1075            g.setColor(text.haloColor);
     1076            Shape textOutline = outline.get();
     1077            g.draw(textOutline);
     1078            g.setStroke(new BasicStroke());
     1079            g.setColor(text.color);
     1080            g.fill(textOutline);
    11221081        } else {
    1123             forEachPolygon(osm, path -> drawAreaText(osm, text, path));
     1082            g.setColor(text.color);
     1083            fill.run();
    11241084        }
    11251085    }
     
    11301090     * @param consumer The consumer to call.
    11311091     */
    1132     private void forEachPolygon(OsmPrimitive osm, Consumer<Path2D.Double> consumer) {
     1092    private void forEachPolygon(OsmPrimitive osm, Consumer<MapViewPath> consumer) {
    11331093        if (osm instanceof Way) {
    11341094            consumer.accept(getPath((Way) osm));
     
    11371097            if (!multipolygon.getOuterWays().isEmpty()) {
    11381098                for (PolyData pd : multipolygon.getCombinedPolygons()) {
    1139                     consumer.accept(pd.get());
     1099                    MapViewPath path = new MapViewPath(mapState);
     1100                    path.appendFromEastNorth(pd.get());
     1101                    consumer.accept(path);
    11401102                }
    11411103            }
     
    11471109     * @param way The way to draw the text on.
    11481110     * @param text The text definition (font/.../text content) to draw.
    1149      */
     1111     * @deprecated Use {@link #drawText(OsmPrimitive, TextLabel)} instead.
     1112     */
     1113    @Deprecated
    11501114    public void drawTextOnPath(Way way, TextLabel text) {
    1151         if (way == null || text == null)
    1152             return;
    1153         String name = text.getString(way);
    1154         if (name == null || name.isEmpty())
    1155             return;
    1156 
    1157         FontMetrics fontMetrics = g.getFontMetrics(text.font);
    1158         Rectangle2D rec = fontMetrics.getStringBounds(name, g);
    1159 
    1160         Rectangle bounds = g.getClipBounds();
    1161 
    1162         List<MapViewPoint> points = way.getNodes().stream().map(mapState::getPointFor).collect(Collectors.toList());
    1163 
    1164         // find half segments that are long enough to draw text on (don't draw text over the cross hair in the center of each segment)
    1165         List<HalfSegment> longHalfSegment = new ArrayList<>();
    1166 
    1167         double pathLength = computePath(2 * (rec.getWidth() + 4), bounds, points, longHalfSegment);
    1168 
    1169         if (rec.getWidth() > pathLength)
    1170             return;
    1171 
    1172         double t1, t2;
    1173 
    1174         if (!longHalfSegment.isEmpty()) {
    1175             // find the segment with the best quality. If there are several with best quality, the one close to the center is prefered.
    1176             Optional<HalfSegment> besto = longHalfSegment.stream().max(
    1177                     Comparator.comparingDouble(segment ->
    1178                         segment.quality - 1e-5 * Math.abs(0.5 * (segment.end + segment.start) - 0.5 * pathLength)
    1179                     ));
    1180             if (!besto.isPresent())
    1181                 throw new IllegalStateException("Unable to find the segment with the best quality for " + way);
    1182             HalfSegment best = besto.get();
    1183             double remaining = best.end - best.start - rec.getWidth(); // total space left and right from the text
    1184             // The space left and right of the text should be distributed 20% - 80% (towards the center),
    1185             // but the smaller space should not be less than 7 px.
    1186             // However, if the total remaining space is less than 14 px, then distribute it evenly.
    1187             double smallerSpace = Math.min(Math.max(0.2 * remaining, 7), 0.5 * remaining);
    1188             if ((best.end + best.start)/2 < pathLength/2) {
    1189                 t2 = best.end - smallerSpace;
    1190                 t1 = t2 - rec.getWidth();
    1191             } else {
    1192                 t1 = best.start + smallerSpace;
    1193                 t2 = t1 + rec.getWidth();
    1194             }
    1195         } else {
    1196             // doesn't fit into one half-segment -> just put it in the center of the way
    1197             t1 = pathLength/2 - rec.getWidth()/2;
    1198             t2 = pathLength/2 + rec.getWidth()/2;
    1199         }
    1200         t1 /= pathLength;
    1201         t2 /= pathLength;
    1202 
    1203         double[] p1 = pointAt(t1, points, pathLength);
    1204         double[] p2 = pointAt(t2, points, pathLength);
    1205 
    1206         if (p1 == null || p2 == null)
    1207             return;
    1208 
    1209         double angleOffset;
    1210         double offsetSign;
    1211         double tStart;
    1212 
    1213         if (p1[0] < p2[0] &&
    1214                 p1[2] < Math.PI/2 &&
    1215                 p1[2] > -Math.PI/2) {
    1216             angleOffset = 0;
    1217             offsetSign = 1;
    1218             tStart = t1;
    1219         } else {
    1220             angleOffset = Math.PI;
    1221             offsetSign = -1;
    1222             tStart = t2;
    1223         }
    1224 
    1225         List<GlyphVector> gvs = Utils.getGlyphVectorsBidi(name, text.font, g.getFontRenderContext());
    1226         double gvOffset = 0;
    1227         for (GlyphVector gv : gvs) {
    1228             double gvWidth = gv.getLogicalBounds().getBounds2D().getWidth();
    1229             for (int i = 0; i < gv.getNumGlyphs(); ++i) {
    1230                 Rectangle2D rect = gv.getGlyphLogicalBounds(i).getBounds2D();
    1231                 double t = tStart + offsetSign * (gvOffset + rect.getX() + rect.getWidth()/2) / pathLength;
    1232                 double[] p = pointAt(t, points, pathLength);
    1233                 if (p != null) {
    1234                     AffineTransform trfm = AffineTransform.getTranslateInstance(p[0] - rect.getX(), p[1]);
    1235                     trfm.rotate(p[2]+angleOffset);
    1236                     double off = -rect.getY() - rect.getHeight()/2 + text.yOffset;
    1237                     trfm.translate(-rect.getWidth()/2, off);
    1238                     if (isGlyphVectorDoubleTranslationBug(text.font)) {
    1239                         // scale the translation components by one half
    1240                         AffineTransform tmp = AffineTransform.getTranslateInstance(-0.5 * trfm.getTranslateX(), -0.5 * trfm.getTranslateY());
    1241                         tmp.concatenate(trfm);
    1242                         trfm = tmp;
    1243                     }
    1244                     gv.setGlyphTransform(i, trfm);
    1245                 }
    1246             }
    1247             displayText(gv, null, 0, 0, way.isDisabled(), text);
    1248             gvOffset += gvWidth;
    1249         }
    1250     }
    1251 
    1252     private static double computePath(double minSegmentLength, Rectangle bounds, List<MapViewPoint> points,
    1253             List<HalfSegment> longHalfSegment) {
    1254         MapViewPoint lastPoint = points.get(0);
    1255         double pathLength = 0;
    1256         for (MapViewPoint p : points.subList(1, points.size())) {
    1257             double segmentLength = p.distanceToInView(lastPoint);
    1258             if (segmentLength > minSegmentLength) {
    1259                 Point2D center = new Point2D.Double((lastPoint.getInViewX() + p.getInViewX())/2, (lastPoint.getInViewY() + p.getInViewY())/2);
    1260                 double q = computeQuality(bounds, lastPoint, center);
    1261                 // prefer the first one for quality equality.
    1262                 longHalfSegment.add(new HalfSegment(pathLength, pathLength + segmentLength / 2, q));
    1263 
    1264                 q = 0;
    1265                 if (bounds != null) {
    1266                     if (bounds.contains(center) && bounds.contains(p.getInView())) {
    1267                         q = 2;
    1268                     } else if (bounds.contains(center) || bounds.contains(p.getInView())) {
    1269                         q = 1;
    1270                     }
    1271                 }
    1272                 longHalfSegment.add(new HalfSegment(pathLength + segmentLength / 2, pathLength + segmentLength, q));
    1273             }
    1274             pathLength += segmentLength;
    1275             lastPoint = p;
    1276         }
    1277         return pathLength;
    1278     }
    1279 
    1280     private static double computeQuality(Rectangle bounds, MapViewPoint p1, Point2D p2) {
    1281         double q = 0;
    1282         if (bounds != null) {
    1283             if (bounds.contains(p1.getInView())) {
    1284                 q += 1;
    1285             }
    1286             if (bounds.contains(p2)) {
    1287                 q += 1;
    1288             }
    1289         }
    1290         return q;
     1115        // NOP.
    12911116    }
    12921117
     
    14841309    }
    14851310
    1486     private static Path2D.Double getPath(Way w) {
    1487         Path2D.Double path = new Path2D.Double();
    1488         boolean initial = true;
    1489         for (Node n : w.getNodes()) {
    1490             EastNorth p = n.getEastNorth();
    1491             if (p != null) {
    1492                 if (initial) {
    1493                     path.moveTo(p.getX(), p.getY());
    1494                     initial = false;
    1495                 } else {
    1496                     path.lineTo(p.getX(), p.getY());
    1497                 }
    1498             }
    1499         }
     1311    private MapViewPath getPath(Way w) {
     1312        MapViewPath path = new MapViewPath(mapState);
    15001313        if (w.isClosed()) {
    1501             path.closePath();
     1314            path.appendClosed(w.getNodes(), false);
     1315        } else {
     1316            path.append(w.getNodes(), false);
    15021317        }
    15031318        return path;
     
    16311446    public boolean isShowNames() {
    16321447        return showNames;
    1633     }
    1634 
    1635     private static double[] pointAt(double t, List<MapViewPoint> poly, double pathLength) {
    1636         double totalLen = t * pathLength;
    1637         double curLen = 0;
    1638         double dx, dy;
    1639         double segLen;
    1640 
    1641         // Yes, it is inefficient to iterate from the beginning for each glyph.
    1642         // Can be optimized if it turns out to be slow.
    1643         for (int i = 1; i < poly.size(); ++i) {
    1644             dx = poly.get(i).getInViewX() - poly.get(i - 1).getInViewX();
    1645             dy = poly.get(i).getInViewY() - poly.get(i - 1).getInViewY();
    1646             segLen = Math.sqrt(dx*dx + dy*dy);
    1647             if (totalLen > curLen + segLen) {
    1648                 curLen += segLen;
    1649                 continue;
    1650             }
    1651             return new double[] {
    1652                     poly.get(i - 1).getInViewX() + (totalLen - curLen) / segLen * dx,
    1653                     poly.get(i - 1).getInViewY() + (totalLen - curLen) / segLen * dy,
    1654                     Math.atan2(dy, dx)};
    1655         }
    1656         return null;
    16571448    }
    16581449
Note: See TracChangeset for help on using the changeset viewer.