Home | History | Annotate | Download | only in zonetree
      1 /*
      2  * Copyright (C) 2018 The Android Open Source Project
      3  *
      4  * Licensed under the Apache License, Version 2.0 (the "License");
      5  * you may not use this file except in compliance with the License.
      6  * You may obtain a copy of the License at
      7  *
      8  *      http://www.apache.org/licenses/LICENSE-2.0
      9  *
     10  * Unless required by applicable law or agreed to in writing, software
     11  * distributed under the License is distributed on an "AS IS" BASIS,
     12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     13  * See the License for the specific language governing permissions and
     14  * limitations under the License.
     15  */
     16 package com.android.libcore.timezone.tzlookup.zonetree;
     17 
     18 import com.android.libcore.timezone.tzlookup.proto.CountryZonesFile;
     19 import com.android.libcore.timezone.tzlookup.proto.CountryZonesFile.Country;
     20 import com.android.libcore.timezone.tzlookup.zonetree.ZoneOffsetPeriod.ZonePeriodsKey;
     21 import com.ibm.icu.text.TimeZoneNames;
     22 import com.ibm.icu.util.BasicTimeZone;
     23 import com.ibm.icu.util.TimeZone;
     24 import com.ibm.icu.util.ULocale;
     25 
     26 import java.io.FileWriter;
     27 import java.io.IOException;
     28 import java.time.Duration;
     29 import java.time.Instant;
     30 import java.util.ArrayList;
     31 import java.util.HashMap;
     32 import java.util.List;
     33 import java.util.Map;
     34 
     35 import static java.util.stream.Collectors.toList;
     36 
     37 /**
     38  * A tree that holds all the zones for a country and records how they relate to each other over
     39  * time.
     40  */
     41 public final class CountryZoneTree {
     42 
     43     /** A Visitor for visiting {@link ZoneNode} trees. */
     44     private interface ZoneNodeVisitor extends TreeNode.Visitor<ZoneNode> {}
     45 
     46     /** A specialist node for zone trees. */
     47     private static class ZoneNode extends TreeNode<ZoneNode> {
     48 
     49         private final int periodOffset;
     50         private final List<ZoneInfo> zoneInfos;
     51 
     52         private int periodCount;
     53         private ZoneInfo primaryZoneInfo;
     54         private boolean priorityClash;
     55 
     56         ZoneNode(String id, List<ZoneInfo> zoneInfos, int periodOffset, int periodCount) {
     57             super(id);
     58             this.periodOffset = periodOffset;
     59             this.zoneInfos = zoneInfos;
     60             this.periodCount = periodCount;
     61             initNodePriority();
     62         }
     63 
     64         private void initNodePriority() {
     65             ZoneInfo priorityCandidate = null;
     66             int priorityCount = 0;
     67             for (ZoneInfo zoneInfo : zoneInfos) {
     68                 if (priorityCandidate == null
     69                         || priorityCandidate.getPriority() < zoneInfo.getPriority()) {
     70                     priorityCandidate = zoneInfo;
     71                     priorityCount = 1;
     72                 } else if (priorityCandidate.getPriority() == zoneInfo.getPriority()) {
     73                     priorityCount++;
     74                 }
     75             }
     76             primaryZoneInfo = priorityCandidate;
     77             // If more than one ZoneInfo has the same priority as the primaryZoneInfo then we
     78             // can't know which one is actually the primary.
     79             priorityClash = priorityCount > 1;
     80         }
     81 
     82         ZoneInfo getPrimaryZoneInfo() {
     83             if (priorityClash) {
     84                 throw new IllegalStateException("No primary zone for " + getId()
     85                         + ": priority clash (between" + getZoneInfosString() + ")");
     86             }
     87             return primaryZoneInfo;
     88         }
     89 
     90         /** {@code true} if multiple zones have the same priority. */
     91         boolean hasPriorityClash() {
     92             return priorityClash;
     93         }
     94 
     95         List<ZoneInfo> getZoneInfos() {
     96             return zoneInfos;
     97         }
     98 
     99         String getZoneInfosString() {
    100             return zoneInfos.stream()
    101                     .map(z -> z.getZoneId() + "(" + z.getPriority() + ")")
    102                     .collect(toList()).toString();
    103         }
    104 
    105         Instant getStartInstant() {
    106             int offset = periodOffset + periodCount - 1;
    107             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - offset;
    108             return primaryZoneInfo.getZoneOffsetPeriod(index).getStartInstant();
    109         }
    110 
    111         Instant getEndInstant() {
    112             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - periodOffset;
    113             return primaryZoneInfo.getZoneOffsetPeriod(index).getEndInstant();
    114         }
    115 
    116         void adjustPeriodCount(int adjustment) {
    117             periodCount += adjustment;
    118         }
    119 
    120         int getPeriodOffset() {
    121             return periodOffset;
    122         }
    123 
    124         int getPeriodCount() {
    125             return periodCount;
    126         }
    127     }
    128 
    129     private final String countryIso;
    130 
    131     private final ZoneNode root;
    132     private final Instant startInclusive;
    133     private final Instant endExclusive;
    134 
    135     private CountryZoneTree(
    136             String countryIso, ZoneNode root, Instant startInclusive, Instant endExclusive) {
    137         this.countryIso = countryIso;
    138         this.root = root;
    139         this.startInclusive = startInclusive;
    140         this.endExclusive = endExclusive;
    141     }
    142 
    143     /**
    144      * Creates a tree for the time zones for a country.
    145      */
    146     public static CountryZoneTree create(
    147             Country country, Instant startInclusive, Instant endExclusive) {
    148         return create(country, startInclusive, endExclusive, true /* compress */);
    149     }
    150 
    151     /**
    152      * Creates a tree for the time zones for a country which can optionally be compressed to remove
    153      * uninteresting nodes (which makes it easier for visualization).
    154      */
    155     public static CountryZoneTree create(
    156             Country country, Instant startInclusive, Instant endExclusive, boolean compress) {
    157 
    158         // We use the US English names for detecting time zone name clashes.
    159         TimeZoneNames timeZoneNames = TimeZoneNames.getInstance(ULocale.US);
    160         List<CountryZonesFile.TimeZoneMapping> timeZoneMappings = country.getTimeZoneMappingsList();
    161 
    162         // Create ZoneInfo objects for every time zone for the time range
    163         // startInclusive -> endExclusive.
    164         List<ZoneInfo> zoneInfos = new ArrayList<>();
    165         for (CountryZonesFile.TimeZoneMapping timeZoneMapping : timeZoneMappings) {
    166             int priority = timeZoneMapping.getPriority();
    167             BasicTimeZone basicTimeZone = getBasicTimeZone(timeZoneMapping.getId());
    168             ZoneInfo zoneInfo = ZoneInfo.create(
    169                     timeZoneNames, basicTimeZone, priority, startInclusive, endExclusive);
    170             zoneInfos.add(zoneInfo);
    171         }
    172 
    173         // Add additional periods to the zone infos to help merging.
    174         addExtraPeriodSplits(timeZoneNames, zoneInfos);
    175 
    176         // The algorithm constructs a tree. The root of the tree contains all ZoneInfos, and at each
    177         // node the ZoneInfos can be split into subsets.
    178         return create(country.getIsoCode(), zoneInfos, startInclusive, endExclusive, compress);
    179     }
    180 
    181     /**
    182      * Inserts artificial periods to the supplied {@link ZoneInfo}s to enable them to be merged
    183      * more easily. The artificial periods are created by splitting existing periods into multiple
    184      * sub-periods.
    185      *
    186      * <p>For example, some time zones do not use DST but will have changed offset or decided to
    187      * stop observing DST at different points in time. To find them we introduce artificial periods
    188      * as needed to make it easy to align and merge those zones.
    189      *
    190      * <ul>
    191      *     <li>Zone A moved to X hours offset from UTC in 1985 and has stayed there</li>
    192      *     <li>Zone B moved to X hours offset from UTC in 1984 and has stayed there</li>
    193      * </ul>
    194      *
    195      * <p>The simple period matching algorithm will not detect that they can be merged after 1985
    196      * because their respective periods using X hours offset from UTC have different start times.
    197      * The solution is to split Zone A into two periods: 1984 -> 1985, and 1985+. Then the
    198      * algorithm will find the match because both Zone A and Zone B have a period from 1985+ with
    199      * the same zone properties.
    200      */
    201     private static void addExtraPeriodSplits(
    202             TimeZoneNames timeZoneNames, List<ZoneInfo> zoneInfos) {
    203 
    204         // This algorithm works backwards through all the zone info periods by incrementing an
    205         // "offset from the end". It looks for periods that "match". In this case "match" means
    206         // "have the same zone properties, or *could* have the same zone properties if it were not
    207         // for different start times". If two or more matching zone offset periods are found with
    208         // different start times it splits the longer ones in two so that periods then exist with
    209         // the same offset with the same start and end times. It keeps going backwards through the
    210         // periods for all the zoneinfos until no two periods with the same offset match.
    211 
    212         // True if any of the zones have the matching properties. When false this is the stopping
    213         // condition for the loop that steps backwards through the zone offset periods.
    214         boolean anyZonesMatch;
    215 
    216         // The offset into the zone offset periods. Since we work in reverse (from the end of time),
    217         // offset = 1 means "the last period" offset = 2 means "the last but one period", etc.
    218         // We initialize at 0; it is incremented at the start of the do-while loop.
    219         int offsetFromEnd = 0;
    220 
    221         // The offsetFromEnd loop: increments offsetFromEnd and processes zone offset periods found
    222         // at that offset.
    223         do {
    224             offsetFromEnd += 1;
    225 
    226             // Reset our stopping condition. The offsetFromEnd loop will stop if this stays false.
    227             anyZonesMatch = false;
    228 
    229             // Keep track of which zoneinfos have been processed for the current offsetFromEnd so
    230             // we don't process them again.
    231             boolean[] processed = new boolean[zoneInfos.size()];
    232 
    233             // The splitting loop: processes groups of zoneinfos with matching periods at offset and
    234             // splits the matching periods as needed.
    235             for (int i = 0; i < zoneInfos.size(); i++) {
    236                 if (processed[i]) {
    237                     // zoneinfo[i] has already been processed by a prior round of the splitting
    238                     // loop. Skip.
    239                     continue;
    240                 }
    241                 processed[i] = true;
    242 
    243                 // Get zoneinfo[i] so we can use its zone properties to find all matching periods.
    244                 ZoneInfo iZoneInfo = zoneInfos.get(i);
    245                 ZoneOffsetPeriod iPeriod =
    246                         getOffsetPeriodAtOffsetFromEndOrNull(iZoneInfo, offsetFromEnd);
    247                 if (iPeriod == null) {
    248                     // We've run out of periods for this zoneinfo. Skip.
    249                     continue;
    250                 }
    251 
    252                 // Pass 1: Find all zoneinfos that have a period at offsetFromEnd that matches the
    253                 // same period at offsetFromEnd from zoneinfo[i]. Also work out the instant that
    254                 // they would need to be split at.
    255                 boolean[] matchAtOffsetFromEnd = new boolean[zoneInfos.size()];
    256                 // zoneinfo[i] matches itself.
    257                 matchAtOffsetFromEnd[i] = true;
    258 
    259                 Instant toSplitInstant = iPeriod.getStartInstant();
    260                 for (int j = i + 1; j < zoneInfos.size(); j++) {
    261                     if (processed[j]) {
    262                         // Don't bother to look any other zoneinfos that have previously been
    263                         // processed.
    264                         continue;
    265                     }
    266 
    267                     ZoneInfo jZoneInfo = zoneInfos.get(j);
    268                     ZoneOffsetPeriod jPeriod =
    269                             getOffsetPeriodAtOffsetFromEndOrNull(jZoneInfo, offsetFromEnd);
    270                     if (jPeriod == null) {
    271                         // We've run out of periods for this zoneinfo. Skip and make sure we don't
    272                         // look at it again.
    273                         processed[j] = true;
    274                         continue;
    275                     }
    276                     if (isMatch(iPeriod, jPeriod)) {
    277                         // At least one pair of zones have similar properties so we get to loop
    278                         // around the outer loop at least once more.
    279                         anyZonesMatch = true;
    280 
    281                         // Mark zoneinfo[j] as being a match for zoneinfo[i] at offset.
    282                         matchAtOffsetFromEnd[j] = true;
    283                         if (jPeriod.getStartInstant().isAfter(toSplitInstant)) {
    284                             // Keep track of the max start instant for pass 2.
    285                             toSplitInstant = jPeriod.getStartInstant();
    286                         }
    287                     }
    288                 }
    289 
    290                 // Pass 2: Split all the periods for the matching zonesinfos at toSplitInstant
    291                 // (if needed).
    292                 for (int j = i; j < zoneInfos.size(); j++) {
    293                     if (!matchAtOffsetFromEnd[j]) {
    294                         continue;
    295                     }
    296                     ZoneInfo jZoneInfo = zoneInfos.get(j);
    297                     int jIndex = jZoneInfo.getZoneOffsetPeriodCount() - offsetFromEnd;
    298                     ZoneOffsetPeriod jPeriod = jZoneInfo.getZoneOffsetPeriod(jIndex);
    299                     if (!jPeriod.getStartInstant().equals(toSplitInstant)) {
    300                         ZoneInfo.splitZoneOffsetPeriodAtTime(
    301                                 timeZoneNames, jZoneInfo, jIndex, toSplitInstant);
    302                     }
    303                     processed[j] = true;
    304                 }
    305             }
    306         } while (anyZonesMatch);
    307     }
    308 
    309     /**
    310      * Returns the {@link ZoneOffsetPeriod} with the specified offset from the end, or null if there
    311      * isn't one.
    312      */
    313     private static ZoneOffsetPeriod getOffsetPeriodAtOffsetFromEndOrNull(
    314             ZoneInfo zoneInfo, int offsetFromEnd) {
    315         int index = zoneInfo.getZoneOffsetPeriodCount() - offsetFromEnd;
    316         if (index < 0) {
    317             return null;
    318         }
    319         return zoneInfo.getZoneOffsetPeriod(index);
    320     }
    321 
    322     /**
    323      * Returns true if the one of the two {@link ZoneOffsetPeriod}s could be split and may be
    324      * identical to the other. The name is ignored since to know the name requires a time. If we
    325      * split and the name turns out not to be the same then it's ok: the different name will ensure
    326      * the periods will not actually be merged.
    327      */
    328     private static boolean isMatch(ZoneOffsetPeriod a, ZoneOffsetPeriod b) {
    329         return a.getEndInstant().equals(b.getEndInstant())
    330                 && a.getDstOffsetMillis() == b.getDstOffsetMillis()
    331                 && a.getRawOffsetMillis() == b.getRawOffsetMillis();
    332     }
    333 
    334     private static CountryZoneTree create(String countryIso, List<ZoneInfo> zoneInfos,
    335             Instant startInclusive, Instant endExclusive, boolean compress) {
    336         // Create a root node with all the information needed to grow the whole tree.
    337         ZoneNode root = new ZoneNode("0", zoneInfos, 0, 0);
    338 
    339         // We call growTree() to build all the branches and leaves from the root.
    340         growTree(root);
    341 
    342         // Now we compress the tree to remove unnecessary nodes if we have been asked to do so.
    343         if (compress) {
    344             compressTree(root);
    345         }
    346 
    347         // Wrap the root and return.
    348         return new CountryZoneTree(countryIso, root, startInclusive, endExclusive);
    349     }
    350 
    351     /**
    352      * Grows the zone tree from the root.
    353      *
    354      * <p>After this step, we have a tree represents a forest. The root node is just a convenience
    355      * for constructing and anchoring the trees in the forest. Below the root, each node
    356      * represents a single period of time where all the zones associated with the node agreed on
    357      * what the local time was and what it was called. The tree grows from the future (the root)
    358      * into the past (the leaves). If a node has a single child it means that the previous
    359      * period (the child) also had every zone in agreement. If a node has zero children it means
    360      * there are no more periods in the past to investigate. If a node has multiple children it
    361      * means that the zones disagreed in the past. Looking at a node with multiple children in
    362      * reverse, from the children to a parent (i.e. going forward in time), it means that
    363      * several zones that previously disagreed were standardized to be the same. The tzdb ID
    364      * exists forever, but if zones have standardized it means that fewer zones are needed to
    365      * represent all possible local times in a given country.
    366      */
    367     private static void growTree(ZoneNode root) {
    368         root.visitSelfThenChildrenRecursive((ZoneNodeVisitor) currentNode -> {
    369             // Increase the period offset by one so that the child will be for one period further
    370             // back.
    371             int newPeriodOffset = currentNode.getPeriodOffset() + 1;
    372 
    373             // Split the zoneinfo set into new sets for the new depth.
    374             List<ZoneInfo> zoneInfosToSplit = currentNode.getZoneInfos();
    375 
    376             // Generate all the child sets.
    377             Map<ZonePeriodsKey, List<ZoneInfo>> newSetsMap = new HashMap<>();
    378             for (ZoneInfo zoneInfo : zoneInfosToSplit) {
    379                 int periodStartIndex = zoneInfo.getZoneOffsetPeriodCount() - newPeriodOffset;
    380                 if (periodStartIndex < 0) {
    381                     // We've run out of ZoneOffsetPeriods. We could declare this a leaf node at this
    382                     // point but we continue for safety to allow the childZoneInfoCount check below.
    383                     continue;
    384                 }
    385                 // Create a zone key for the zoneInfo. We only need to look at one period each time
    386                 // as we know all periods after this point have to agree (otherwise they wouldn't
    387                 // have been lumped together in a single node).
    388                 ZonePeriodsKey zoneKey =
    389                         zoneInfo.createZonePeriodsKey(periodStartIndex, periodStartIndex + 1);
    390                 List<ZoneInfo> identicalTimeZones =
    391                         newSetsMap.computeIfAbsent(zoneKey, k -> new ArrayList<>());
    392                 identicalTimeZones.add(zoneInfo);
    393             }
    394 
    395             // Construct any child nodes.
    396             int childZoneInfoCount = 0;
    397             int i = 1;
    398             for (Map.Entry<ZonePeriodsKey, List<ZoneInfo>> newSetEntry : newSetsMap.entrySet()) {
    399                 List<ZoneInfo> newSet = newSetEntry.getValue();
    400                 childZoneInfoCount += newSet.size();
    401                 // The child ID is just the {parent ID}.{child number} so we create an easy-to-debug
    402                 // address.
    403                 String childId = currentNode.getId() + "." + i;
    404                 ZoneNode e = new ZoneNode(childId, newSet, newPeriodOffset, 1 /* periodCount */);
    405                 currentNode.addChild(e);
    406                 i++;
    407             }
    408 
    409             // Assertion: a node should either have no nodes (be a leaf) or all zones should have
    410             // been split between the children.
    411             if (childZoneInfoCount != 0 && childZoneInfoCount != zoneInfosToSplit.size()) {
    412                 // This implies some kind of data issue.
    413                 throw new IllegalStateException();
    414             }
    415         });
    416     }
    417 
    418     /**
    419      * Removes uninteresting nodes from the tree by merging them with their children where possible.
    420      * Uninteresting nodes are those that have a single child; having a single child implies the
    421      * node and its child have the same offsets and other information (they're just for an earlier
    422      * period). The resulting merged node has the same zones and depthInTree but a larger period
    423      * count.
    424      */
    425     private static void compressTree(ZoneNode root) {
    426         class CompressionVisitor implements ZoneNodeVisitor {
    427 
    428             @Override
    429             public void visit(ZoneNode node) {
    430                 if (node.isRoot()) {
    431                     // Ignore the root.
    432                     return;
    433                 }
    434 
    435                 // If there's one child then we can compress it into node.
    436                 if (node.getChildrenCount() == 1) {
    437                     ZoneNode child = node.getChildren().get(0);
    438 
    439                     // Remove the child from node.
    440                     node.removeChild(child);
    441 
    442                     // The child may also have descendants with a single child, so handle those too.
    443                     int periodCountAdjustment = child.getPeriodCount();
    444                     ZoneNode descendant = child;
    445                     while (descendant.getChildrenCount() == 1) {
    446                         descendant = descendant.getChildren().get(0);
    447                         periodCountAdjustment += descendant.getPeriodCount();
    448                     }
    449 
    450                     // Remove the children from descendant and add them to node.
    451                     for (ZoneNode newChild : descendant.getChildren()) {
    452                         descendant.removeChild(newChild);
    453                         node.addChild(newChild);
    454                     }
    455 
    456                     // Add the removed descendant's period count to node.
    457                     node.adjustPeriodCount(periodCountAdjustment);
    458                 }
    459             }
    460         }
    461         root.visitSelfThenChildrenRecursive(new CompressionVisitor());
    462     }
    463 
    464     /** Validates the tree has no nodes with priority clashes, returns a list of issues. */
    465     public List<String> validateNoPriorityClashes() {
    466         class ValidationVisitor implements ZoneNodeVisitor {
    467             private final List<String> issues = new ArrayList<>();
    468 
    469             @Override
    470             public void visit(ZoneNode node) {
    471                 if (node.isRoot()) {
    472                     // Ignore the root, it's not a "real" node and will usually clash in countries
    473                     // where there's more than one zone.
    474                     return;
    475                 }
    476 
    477                 if (node.hasPriorityClash()) {
    478                     String issue = node.getZoneInfosString();
    479                     issues.add(issue);
    480                 }
    481             }
    482 
    483             public List<String> getIssues() {
    484                 return issues;
    485             }
    486         }
    487 
    488         ValidationVisitor visitor = new ValidationVisitor();
    489         root.visitSelfThenChildrenRecursive(visitor);
    490         return visitor.getIssues();
    491     }
    492 
    493     /**
    494      * Creates a {@link CountryZoneUsage} object from the tree.
    495      */
    496     public CountryZoneUsage calculateCountryZoneUsage(Instant notAfterCutOff) {
    497         class CountryZoneVisibilityVisitor implements ZoneNodeVisitor {
    498             private final CountryZoneUsage zoneUsage = new CountryZoneUsage(countryIso);
    499 
    500             @Override
    501             public void visit(ZoneNode node) {
    502                 // We ignore the root.
    503                 if (node.isRoot()) {
    504                     return;
    505                 }
    506 
    507                 if (node.hasPriorityClash()) {
    508                     throw new IllegalStateException(
    509                             "Cannot calculate zone usage with priority clashes present");
    510                 }
    511 
    512                 Instant endInstant = node.getEndInstant();
    513                 if (!node.isLeaf()) {
    514                     ZoneInfo primaryZone = node.getPrimaryZoneInfo();
    515                     addZoneEntryIfMissing(endInstant, primaryZone);
    516                 } else {
    517                     // In some rare cases (e.g. Canada: Swift_Current and Creston) zones have agreed
    518                     // completely since 1970 so some leaves may have multiple zones. So, attempt to
    519                     // add all zones for leaves, not just the primary.
    520                     for (ZoneInfo zoneInfo : node.getZoneInfos()) {
    521                         addZoneEntryIfMissing(endInstant, zoneInfo);
    522                     }
    523                 }
    524             }
    525 
    526             private void addZoneEntryIfMissing(Instant endInstant, ZoneInfo zoneInfo) {
    527                 String zoneId = zoneInfo.getZoneId();
    528                 if (!notAfterCutOff.isAfter(endInstant)) {
    529                     // notAfterCutOff <= endInstant
    530                     endInstant = null;
    531                 }
    532                 if (!zoneUsage.hasEntry(zoneId)) {
    533                     zoneUsage.addEntry(zoneId, endInstant);
    534                 }
    535             }
    536 
    537             private CountryZoneUsage getCountryZoneUsage() {
    538                 return zoneUsage;
    539             }
    540         }
    541 
    542         CountryZoneVisibilityVisitor visitor = new CountryZoneVisibilityVisitor();
    543         root.visitSelfThenChildrenRecursive(visitor);
    544         return visitor.getCountryZoneUsage();
    545     }
    546 
    547     /**
    548      * Creates a Graphviz file for visualizing the tree.
    549      */
    550     public void createGraphvizFile(String outputFile) throws IOException {
    551         class DotFileVisitor implements ZoneNodeVisitor {
    552             private StringBuilder graphStringBuilder = new StringBuilder();
    553 
    554             @Override
    555             public void visit(ZoneNode node) {
    556                 if (node.isRoot()) {
    557                     // Don't draw the root - make the tree look like a forest.
    558                     return;
    559                 }
    560 
    561                 String nodeName = enquote(node.getId());
    562 
    563                 // Draw the node.
    564                 Instant startInstant = node.getStartInstant();
    565                 Instant endInstant = node.getEndInstant();
    566                 boolean priorityClash = node.hasPriorityClash();
    567 
    568                 String fromTimestamp = startInstant.toString();
    569                 String toTimestamp = endInstant.toString();
    570                 String optionalColor = priorityClash ? ",color=\"red\"" : "";
    571                 String label = node.getZoneInfosString()
    572                         + "\nFrom=" + fromTimestamp + " to " + toTimestamp
    573                         + "\nPeriod count=" + node.getPeriodCount();
    574                 if (node.getPeriodCount() == 1) {
    575                     ZoneInfo arbitraryZoneInfo = node.getZoneInfos().get(0);
    576                     int periodIndex =
    577                             arbitraryZoneInfo.getZoneOffsetPeriodCount() - node.getPeriodOffset();
    578                     ZoneOffsetPeriod zoneOffsetPeriod =
    579                             arbitraryZoneInfo.getZoneOffsetPeriod(periodIndex);
    580                     label += "\nrawOffset=" + durationString(zoneOffsetPeriod.getRawOffsetMillis());
    581                     label += "\ndstOffset=" + durationString(zoneOffsetPeriod.getDstOffsetMillis());
    582                     label += "\nname=" + zoneOffsetPeriod.getName();
    583                 }
    584                 writeLine(nodeName + "[label=" + enquote(label) + optionalColor + "];");
    585 
    586                 // Link the node to its children.
    587                 for (ZoneNode child : node.getChildren()) {
    588                     writeLine(nodeName + " -> " + enquote(child.getId()) + ";");
    589                 }
    590             }
    591 
    592             private String durationString(int durationMillis) {
    593                 return Duration.ofMillis(durationMillis).toString();
    594             }
    595 
    596             private String enquote(String toQuote) {
    597                 return "\"" + toQuote + "\"";
    598             }
    599 
    600             private void writeLine(String s) {
    601                 graphStringBuilder.append(s);
    602                 graphStringBuilder.append('\n');
    603             }
    604         }
    605 
    606         DotFileVisitor dotFileVisitor = new DotFileVisitor();
    607         root.visitSelfThenChildrenRecursive(dotFileVisitor);
    608 
    609         try (FileWriter fileWriter = new FileWriter(outputFile)) {
    610             writeLine(fileWriter, "strict digraph " + countryIso + " {");
    611             writeLine(fileWriter, dotFileVisitor.graphStringBuilder.toString());
    612             writeLine(fileWriter, "}");
    613         }
    614     }
    615 
    616     private static void writeLine(Appendable appendable, String s) throws IOException {
    617         appendable.append(s);
    618         appendable.append('\n');
    619     }
    620 
    621     /**
    622      * Returns an ICU {@link BasicTimeZone} with the specified ID or throws an exception if there
    623      * isn't one.
    624      */
    625     private static BasicTimeZone getBasicTimeZone(String zoneId) {
    626         TimeZone timeZone = TimeZone.getTimeZone(zoneId);
    627         if (isInvalidZone(timeZone)) {
    628             throw new IllegalArgumentException(
    629                     "Unknown or unexpected type for zone id: " + timeZone.getID());
    630         }
    631         return (BasicTimeZone) timeZone;
    632     }
    633 
    634     private static boolean isInvalidZone(TimeZone timeZone) {
    635         return !(timeZone instanceof BasicTimeZone)
    636                 || timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID);
    637     }
    638 }
    639