• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
ZoneNode(String id, List<ZoneInfo> zoneInfos, int periodOffset, int periodCount)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 
initNodePriority()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 
getPrimaryZoneInfo()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. */
hasPriorityClash()91         boolean hasPriorityClash() {
92             return priorityClash;
93         }
94 
getZoneInfos()95         List<ZoneInfo> getZoneInfos() {
96             return zoneInfos;
97         }
98 
getZoneInfosString()99         String getZoneInfosString() {
100             return zoneInfos.stream()
101                     .map(z -> z.getZoneId() + "(" + z.getPriority() + ")")
102                     .collect(toList()).toString();
103         }
104 
getStartInstant()105         Instant getStartInstant() {
106             int offset = periodOffset + periodCount - 1;
107             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - offset;
108             return primaryZoneInfo.getZoneOffsetPeriod(index).getStartInstant();
109         }
110 
getEndInstant()111         Instant getEndInstant() {
112             int index = primaryZoneInfo.getZoneOffsetPeriodCount() - periodOffset;
113             return primaryZoneInfo.getZoneOffsetPeriod(index).getEndInstant();
114         }
115 
adjustPeriodCount(int adjustment)116         void adjustPeriodCount(int adjustment) {
117             periodCount += adjustment;
118         }
119 
getPeriodOffset()120         int getPeriodOffset() {
121             return periodOffset;
122         }
123 
getPeriodCount()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 
CountryZoneTree( String countryIso, ZoneNode root, Instant startInclusive, Instant endExclusive)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      */
create( Country country, Instant startInclusive, Instant endExclusive)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      */
create( Country country, Instant startInclusive, Instant endExclusive, boolean compress)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      */
addExtraPeriodSplits( TimeZoneNames timeZoneNames, List<ZoneInfo> zoneInfos)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      */
getOffsetPeriodAtOffsetFromEndOrNull( ZoneInfo zoneInfo, int offsetFromEnd)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      */
isMatch(ZoneOffsetPeriod a, ZoneOffsetPeriod b)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 
create(String countryIso, List<ZoneInfo> zoneInfos, Instant startInclusive, Instant endExclusive, boolean compress)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      */
growTree(ZoneNode root)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      */
compressTree(ZoneNode root)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. */
validateNoPriorityClashes()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      */
calculateCountryZoneUsage(Instant notAfterCutOff)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      */
createGraphvizFile(String outputFile)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 
writeLine(Appendable appendable, String s)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      */
getBasicTimeZone(String zoneId)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 
isInvalidZone(TimeZone timeZone)634     private static boolean isInvalidZone(TimeZone timeZone) {
635         return !(timeZone instanceof BasicTimeZone)
636                 || timeZone.getID().equals(TimeZone.UNKNOWN_ZONE_ID);
637     }
638 }
639