34declare(strict_types=1);
73 $geometry = $feature[
'geometry'];
74 if ($geometry[
'type'] ===
'Polygon') {
75 $polygons = [$geometry[
'coordinates']];
76 } elseif ($geometry[
'type'] ===
'MultiPolygon') {
77 $polygons = $geometry[
'coordinates'];
79 throw new RuntimeException(
'Unsupported source geometry: '.$geometry[
'type']);
81 foreach ($polygons as $polygon) {
83 $bounds = [
'east' => -INF,
'west' => INF,
'north' => -INF,
'south' => INF];
84 foreach ($polygon as $ringIndex => $ring) {
86 foreach ($ring as $point) {
87 if (count($point) !== 2) {
88 throw new RuntimeException(
'Expected two-dimensional source coordinates.');
90 $packed .= pack(
'd2', round($point[0], 6), round($point[1], 6));
91 if ($ringIndex === 0) {
92 $bounds[
'east'] = max($bounds[
'east'], $point[0]);
93 $bounds[
'west'] = min($bounds[
'west'], $point[0]);
94 $bounds[
'north'] = max($bounds[
'north'], $point[1]);
95 $bounds[
'south'] = min($bounds[
'south'], $point[1]);
101 throw new RuntimeException(
'The source contains an empty or degenerate polygon.');
103 $count = intdiv(strlen(
$rings[0]), 16);
104 $id = count($this->shapes) + 1;
105 $this->shapes[$id] = [
106 'id' => $id,
'tzname' => $feature[
'properties'][
'tzid'],
'rings' =>
$rings,
107 'bounds' => $bounds,
'vertices' => $count,
'bytes' => strlen(
$rings[0]),
108 'sha256' => hash(
'sha256',
$rings[0]),
110 $this->vertices += $count;
111 $this->polygonBytes += strlen(
$rings[0]);
126 $shapes = array_values($this->shapes);
127 usort(
$shapes,
static fn($a, $b) => ($b[
'vertices'] <=> $a[
'vertices']) ?: ($a[
'id'] <=> $b[
'id']));
128 return array_slice(
$shapes, 0, $count);
145 $bytes = strlen($ring);
146 if ($bytes < 48) {
return false; }
147 $previous = unpack(
'dx/dy', $ring, $bytes - 16);
148 $previousX = $previous[
'x'];
149 $previousY = $previous[
'y'];
151 for ($offset = 0; $offset < $bytes; $offset += 16384) {
152 $count = min(2048, intdiv($bytes - $offset, 8));
153 $coordinates = unpack(
'd'.$count, $ring, $offset);
154 for ($i = 1; $i < $count; $i += 2) {
155 $x = $coordinates[$i];
156 $y = $coordinates[$i + 1];
157 if (($previousY > $lat) !== ($y > $lat) &&
158 $lng < $previousX + ($x - $previousX) * ($lat - $previousY) / ($y - $previousY)) {
181 $bounds = $shape[
'bounds'];
182 if ($lng < $bounds[
'west'] || $lng > $bounds[
'east'] || $lat < $bounds[
'south'] || $lat > $bounds[
'north'] ||
183 !self::ringContains($shape[
'rings'][0], $lng, $lat)) {
187 for ($i = 1; $i < count($shape[
'rings']); ++$i) {
188 if (self::ringContains($shape[
'rings'][$i], $lng, $lat)) {
return false; }
207 foreach ($this->shapes as $shape) {
208 if (!str_starts_with($shape[
'tzname'],
'Etc/') && self::contains($shape, $lng, $lat)) {
209 $named[$shape[
'tzname']] =
true;
212 if (!empty($named)) {
213 $zones = array_keys($named);
215 foreach ($this->shapes as $shape) {
216 if (str_starts_with($shape[
'tzname'],
'Etc/') && self::contains($shape, $lng, $lat)) {
217 $ocean[$shape[
'tzname']] =
true;
220 $zones = array_keys($ocean);
240 $hash =
$connection->getAttribute(PDO::ATTR_DRIVER_NAME) ===
'pgsql' ?
"encode(sha256(polygon), 'hex')" :
'SHA2(polygon, 256)';
241 $rows =
$connection->query(
'SELECT id, tzname, OCTET_LENGTH(polygon) AS bytes, '.$hash.
' AS sha256,
242 east + 0.0 AS east, west + 0.0 AS west, north + 0.0 AS north, south + 0.0 AS south
243 FROM timezones ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
245 if (count(
$rows) !== count($this->shapes)) {
246 $failures[] =
'Source/database polygon counts differ: '.count($this->shapes).
' versus '.count(
$rows);
248 foreach (
$rows as $row) {
249 $source = $this->shapes[(int)$row[
'id']] ??
null;
251 (
int)$row[
'bytes'] !==
$source[
'bytes'] || $row[
'sha256'] !==
$source[
'sha256']) {
252 $failures[] =
'Stored geometry/name mismatch for polygon '.$row[
'id'].
' ('.$row[
'tzname'].
')';
255 foreach (
$source[
'bounds'] as $edge => $value) {
257 if (abs((
float)$row[$edge] - $value) > 0.00002) {
258 $failures[] =
'Stored '.$edge.
' bound mismatch for polygon '.$row[
'id'];
281 float $distanceKm = 5.0
284 foreach ($this->
largest($count) as $shape) {
285 $ring = $shape[
'rings'][0];
289 for ($sample = 0; $sample < 8; ++$sample) { $indices[] = (int)floor($sample * (
$vertices - 1) / 8); }
291 $indices = array_unique(array_merge($indices, [1023, 1024, 2047, 2048,
$vertices - 2]));
292 foreach ($indices as $index) {
293 if ($index >=
$vertices - 1) {
continue; }
294 $a = unpack(
'dx/dy', $ring, $index * 16);
295 $b = unpack(
'dx/dy', $ring, (($index + 1) %
$vertices) * 16);
296 $latitude = ($a[
'y'] + $b[
'y']) / 2;
297 $cosine = cos(deg2rad($latitude));
298 if (abs($cosine) < 0.02 || abs($b[
'x'] - $a[
'x']) > 180) {
continue; }
299 $dx = ($b[
'x'] - $a[
'x']) * $cosine;
300 $dy = $b[
'y'] - $a[
'y'];
301 $length = hypot($dx, $dy);
302 if ($length === 0.0) {
continue; }
303 foreach ([-1, 1] as $side) {
304 $lng = ($a[
'x'] + $b[
'x']) / 2 - $side * $dy / $length * $distanceKm / (111.32 * $cosine);
305 $lat = $latitude + $side * $dx / $length * $distanceKm / 111.32;
306 while ($lng < -180) { $lng += 360; }
307 while ($lng > 180) { $lng -= 360; }
308 if (abs($lat) >= 89.9) {
continue; }
310 'title' => $shape[
'tzname'].
' polygon '.$shape[
'id'].
' border vertex '.$index.
' side '.$side,
311 'params' => [
'lng' => $lng,
'lat' => $lat],
'expected' => $this->
zonesAt($lng, $lat),
312 'kind' =>
'generated boundary',
'pair' => $shape[
'id'].
':'.$index,
'side' => $side,
317 foreach ([0.37, 0.59, 0.73] as $x) {
318 foreach ([0.37, 0.59, 0.73] as $y) {
319 $lng = $shape[
'bounds'][
'west'] + $x * ($shape[
'bounds'][
'east'] - $shape[
'bounds'][
'west']);
320 $lat = $shape[
'bounds'][
'south'] + $y * ($shape[
'bounds'][
'north'] - $shape[
'bounds'][
'south']);
321 if ($inside < 3 && self::contains($shape, $lng, $lat)) {
323 $cases[] = [
'title' => $shape[
'tzname'].
' polygon '.$shape[
'id'].
' interior '.$inside,
324 'params' => [
'lng' => $lng,
'lat' => $lat],
'expected' => $this->
zonesAt($lng, $lat),
325 'kind' =>
'generated interior'];
367 $ordered = array_map(
'intval', $ids);
368 sort($ordered, SORT_NUMERIC);
370 foreach (parent::get_tz_polygons($ids) as $row) {
372 $this->polygonBytes += strlen($row[
'polygon']);
373 if ($this->generatedCall) { $this->generatedPolygons[$ordered[$index]] =
true; }
409 public function startDocument(): void { $this->depth = 0; $this->loader->startDocument(); }
415 public function startObject(): void { ++$this->depth; $this->loader->startObject(); }
421 if ($this->depth === 2) { $this->source->observe($this->loader->getJson()); }
425 public function startArray(): void { ++$this->depth; $this->loader->startArray(); }
431 public function key(
string $key
432 ): void { $this->loader->
key($key); }
436 ): void { $this->loader->
value($value); }
440 ): void { $this->loader->
whitespace($whitespace); }
This forwards parser events to the installed loader and observes the decoded source features.
key(string $key)
This forwards a JSON object key.
__construct(LGV_TZ_Lookup_Loader $loader, DemoSourceGeometry $source)
This connects the installed loader with the independent source collector.
endArray()
This forwards a completed array and updates nesting.
value($value)
This forwards a JSON scalar value without modification.
whitespace(string $whitespace)
This forwards whitespace reported by the parser.
LGV_TZ_Lookup_Loader $loader
This is the installed package's listener, which performs the actual database load.
endObject()
This forwards a completed object, collecting source geometry when that object is a Feature.
startObject()
This records entry into an object, and forwards that event.
startArray()
This records entry into an array, and forwards that event.
startDocument()
This resets nesting and forwards the start-of-document event.
endDocument()
This forwards the end-of-document event.
int $depth
This tracks object/array nesting; a Feature ends when nesting returns to level two.
DemoSourceGeometry $source
This receives the decoded source polygons for independent validation.
This retains a compact reference copy of each polygon in the decoded GeoJSON.
largest(int $count)
This selects the most detailed polygons, using exterior-ring point counts.
int $polygonBytes
This is the total expected size, in bytes, of the stored exterior-ring polygon data.
array $shapes
These are the source polygon records, keyed by their expected database row IDs.
validateStorage(PDO $connection)
This compares every stored polygon with its decoded source record.
static contains(array $shape, float $lng, float $lat)
This checks a point against a complete source polygon, including its holes.
observe(array $feature)
This records the polygons from one decoded GeoJSON Feature.
static ringContains(string $ring, float $lng, float $lat)
This checks a point against one reference ring, using horizontal ray casting.
int $vertices
This is the total number of exterior-ring points, including repeated closing points.
casesForLargest(int $count, float $distanceKm=5.0)
This generates boundary and interior tests for the largest polygons.
zonesAt(float $lng, float $lat)
This finds all acceptable time zone names at a point, using the source geometry.
This adds audit counters around the installed package's normal polygon reader.
int $polygonBytes
This sums the bytes yielded across lookup calls; repeated evaluations are counted again.
int $polygonRows
This counts polygon rows actually evaluated across all lookup calls.
array $generatedPolygons
These row IDs were evaluated by generated calls, and are used to check largest-polygon coverage.
bool $generatedCall
This is true while a generated test is being measured, and false for the known-location tests.
get_tz_polygons($ids)
This forwards the package's polygon iterator, recording each yielded row.
if(PHP_VERSION_ID< 80100) $rings