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