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