1/* 2 * Services search â the live map. 3 * 4 * Loaded only when a browser key is configured; without one the page keeps its 5 * static image and this file is never requested. See config/maps.php. 6 * 7 * IT READS THE ROWS. Each result carries its own coordinates on the element, 8 * the same markup the list and the filters work from, so a marker cannot drift 9 * out of step with the row it belongs to. There is no second copy of the data 10 * here and no fetch of its own. 11 * 12 * WHAT IT OWNS AND WHAT IT DOES NOT. This file draws the map and its markers 13 * and reports clicks. Which rows exist, which are visible and which is open is 14 * services-search.js's business â the two talk through events rather than 15 * reaching into each other. 16 */ 17(function () { 18 'use strict'; 19 20 /* 21 * TWO BLUES, AND THEY ARE NOT INTERCHANGEABLE. 22 * 23 * The radius ring is the website's brand ink: the design source draws it as 24 * `rgba(22,136,202,...)`, which is --tp-brand. 25 * 26 * The pin is not. `search/map-pin.svg`, the marker the design source places 27 * on both of its maps, is drawn in #0277AD â the POI ink the app uses, a 28 * darker blue that holds up against the road layer underneath it. Pointing 29 * the marker at --tp-brand made the two agree on screen and disagree with 30 * the asset; they are separate decisions and are kept separate here. 31 */ 32 var RING_COLOUR = '#1688ca'; 33 var PIN_COLOUR = '#0277AD'; 34 35 /* 36 * Where the tip sits inside the 34x44 pin box. The overlay subtracts these 37 * from the projected pixel so the pin points at its coordinate â the job 38 * Marker's `anchor` used to do, moved to `left`/`top` because `transform` 39 * now belongs to the hover. 40 */ 41 var PIN_TIP_X = 17; 42 var PIN_TIP_Y = 40; 43 44 /* 45 * How far the 36px category icons are scaled down to sit on the pin's head. 46 * 16px is what the design source sets on the glyph over the pin. 47 * Read twice: once by the transform, once to scale the stroke back up. 48 */ 49 var GLYPH_SCALE = 0.4444; 50 51 /* The design source's own dashed radius ring. */ 52 var RING_FILL_OPACITY = 0.18; 53 var RING_STROKE_OPACITY = 0.7; 54 var METRES_PER_MILE = 1609.344; 55 56 var state = { 57 map: null, 58 markers: {}, 59 ring: null, 60 ringEdge: null, 61 ready: false, 62 63 /* 64 * THE LAST CENTRE THIS MAP ASKED FOR, and the whole of how a drag survives 65 * the answer it causes. See isOwnCentre(). 66 */ 67 mine: null 68 }; 69 70 // Close enough to be the same point. The value makes a round trip through a 71 // DOM attribute â number to string and back â and nothing else on this page 72 // distinguishes positions at a millionth of a degree (about 10cm). 73 var SAME_POINT = 0.000001; 74 75 /* 76 * WHAT IS AT THE POINT THE MAP WAS LEFT AT. 77 * 78 * ASKED OF OUR OWN SERVER, NOT OF GOOGLE. This called `google.maps.Geocoder` 79 * until 2026-09-24 and never once produced a name. Two reasons in sequence: 80 * the class is in the `geocoding` library rather than in core, so under 81 * `loading=async&libraries=geometry` it is simply undefined; and with that 82 * fixed every call came back `REQUEST_DENIED`, because the browser key's 83 * API-restriction list does not carry the Geocoding API. That is a Cloud 84 * Console setting, and a browser key is the wrong place to hold the 85 * permission anyway. 86 * 87 * The upstream answers the same question and the server is already signed 88 * for it â see ServicesReverseGeocodeApi, which is also where `City, ST` is 89 * decided. Nothing here parses an address. 90 * 91 * ONE CALL PER POINT. A driver comparing two towns drags back and forth, and 92 * the cache is what stops the same point being asked about every time. Keyed 93 * at three decimals, the same precision the coordinates are written at â 94 * about a hundred metres, finer than any label this produces. 95 */ 96 var NAME_PATH = '/services/reverse-geocode'; 97 var named = {}; 98 99 /* 100 * How long the rest of the drag waits for a name. See nameCentre(). 101 * 102 * FIVE SECONDS, RAISED FROM ONE ON 2026-09-24, AND IT IS A BACKSTOP RATHER 103 * THAN A BUDGET. One was chosen while this called Google and was believed to 104 * take "a few hundred milliseconds". Measured against the endpoint that 105 * actually answers it: 106 * 107 * upstream, connection reused 0.31 â 0.42s (15 fresh points) 108 * through our route, cold socket 0.70 â 1.32s (TCP 0.22 + TLS 0.23) 109 * an occasional connect stall up to ~5.9s 110 * 111 * So one second sat on top of the median: 0.86s won, 1.03s lost, and the box 112 * kept its coordinates about half the time while the name arrived a moment 113 * later and went into the cache â which is why dragging BACK to a point
114 * named it and the first visit did not. 115 * 116 * THE SERVER GIVES UP FIRST, deliberately. ServicesReverseGeocodeApi allows 117 * 2s to connect and 4s in total and then answers 503, so this deadline is 118 * above anything the route can still be about to answer. It fires only when 119 * the request itself is gone â offline, or a socket that never closes. 120 */ 121 var NAME_MS = 5000; 122 123 /* 124 * SAID ONCE, AND ONLY FOR THINGS SOMEBODY CAN FIX. 125 * 126 * Not an error: nothing is broken for the driver, who has coordinates in the 127 * box and a map that moved. It is for whoever is wondering why the names 128 * never appear, and repeating it on every drag would bury it. 129 */ 130 var warned = {}; 131 132 function warn(message) { 133 if (warned[message] || !window.console || !window.console.warn) { 134 return; 135 } 136 137 warned[message] = true; 138 window.console.warn('Services map: ' + message); 139 } 140 141 /* 142 * Ask what is at a point, and say so once â or never. 143 * 144 * THE ANSWER IS CHECKED AGAINST THE CENTRE IT WAS ASKED ABOUT. A geocode for 145 * a drag the driver has already moved on from would relabel a search it is 146 * not about, and the reply order is not the request order. 147 */ 148 function nameCentre(lat, lon, done) { 149 var settled = false; 150 151 function finish(label) { 152 if (settled) { 153 return; 154 } 155 156 settled = true; 157 done(label); 158 } 159 160 /* 161 * THE SEARCH IS NOT ALLOWED TO WAIT INDEFINITELY FOR A NAME. 162 * 163 * Everything after the drag is behind this answer now â the box, the ring, 164 * the request â so a geocode that hangs would hold the whole page on a 165 * view the driver has already left. A second is longer than this call 166 * takes when it works (a few hundred milliseconds) and short enough that 167 * a driver does not sit looking at the old town. 168 */ 169 window.setTimeout(function () { finish(''); }, NAME_MS); 170 171 // NOT A GOOGLE GUARD ANY MORE. This used to test for `google.maps` 172 // because the geocoder came from there; naming a point is our own server 173 // now and the only thing it needs is fetch. A browser without one keeps 174 // the coordinates, which is what it would have got anyway. 175 if (!window.fetch) { 176 warn('no fetch in this browser; points stay as coordinates'); 177 finish(''); 178 return; 179 } 180 181 var key = lat.toFixed(3) + ',' + lon.toFixed(3); 182 183 if (Object.prototype.hasOwnProperty.call(named, key)) { 184 finish(named[key]); 185 return; 186 } 187 188 /* 189 * THE ANSWER IS CACHED EVEN WHEN NOBODY IS STILL WAITING FOR IT. 190 * 191 * `finish()` may already have fired on the deadline above; the reply is 192 * still true about this point, and writing it here is what makes dragging 193 * back to a town instant rather than another round trip. 194 */ 195 function remember(label) { 196 named[key] = label; 197 finish(label); 198 } 199 200 window.fetch(NAME_PATH + '?lat=' + encodeURIComponent(lat) + '&lon=' + encodeURIComponent(lon), { 201 // No cookie is needed to name a point, and none should travel. 202 credentials: 'omit', 203 headers: { Accept: 'application/json' } 204 }).then(function (response) { 205 /* 206 * 200 IS THE ONLY STATUS THAT CARRIES AN ANSWER, and an empty label 207 * under it is one: the sea, or a point the upstream recognises nothing 208 * at. It passes without a word. 209 * 210 * 400 and 503 are worth saying once. Nothing is broken for the driver â 211 * the coordinates in the box are true â but "the box shows numbers" has 212 * three causes that look identical from outside, and silence made the 213 * last one of these take two rounds of guessing to find. 214 */ 215 if (!response.ok) { 216 // NOT REMEMBERED. A refusal is about this moment, not about this 217 // point: caching it would leave the box showing numbers for the rest 218 // of the session even after the upstream came back. 219 warn('reverse geocode answered ' + response.status); 220 finish(''); 221 return; 222 } 223 224 return response.json().then(function (data) { 225 remember(data && typeof data.label === 'string' ? data.label : ''); 226 }); 227 }).catch(function () { 228 // Offline, aborted, or an answer that was not JSON. The coordinates 229 // stand; they always did. 230 warn('reverse geocode could not be reached'); 231 finish(''); 232 }); 233 } 234 235 /* 236 * Put the radius where the driver left the map, before any data arrives. 237 * 238 * THE RING IS THE ANSWER TO "WHAT AM I LOOKING AT", and it used to wait for 239 * the search: services-search.js writes these attributes when the results 240 * land, so for the length of a round trip the circle sat over the town the 241 * driver had dragged away from. Writing them here moves it at once and the 242 * answer, when it comes, writes the same values. 243 * 244 * It also makes isOwnCentre() true immediately, which is the same statement 245 * from the other side: from now until something else moves the search, every 246 * render is about the view the driver chose and none of them may re-frame it. 247 */ 248 function moveRing(lat, lon) { 249 var host = container(); 250 251 if (!host) { 252 return; 253 } 254 255 host.setAttribute('data-map-lat', lat); 256 host.setAttribute('data-map-lon', lon); 257 258 render(); 259 } 260 261 /* 262 * The category glyphs, fetched once each and kept as pin markup. 263 * 264 * A marker takes one image, so the pin and the glyph have to be composed 265 * into a single SVG before it can be handed over. The icon files are this 266 * site's own, 36x36, drawn in one ink â some as strokes and some as fills â 267 * so the ink is swapped for white and the whole thing is inlined. 268 * 269 * Cached by URL: three to eleven small files, asked for once per page. 270 */ 271 var glyphs = {}; 272 273 function container() { 274 return document.querySelector('[data-services-map="live"]'); 275 } 276 277 /* 278 * Is the search now centred where this map last put it? 279 * 280 * THE TEST IS THE POINT, NOT A FLAG, and that is worth the paragraph. 281 * 282 * A flag saying "skip the next re-frame" has to be cleared by something, and 283 * render() is not called once per answer: the glyph fetch re-enters it once 284 * per distinct category icon on first paint. A flag would be spent by the 285 * first of those and the second would re-frame anyway. 286 * 287 * Comparing the centre has no such timing. `data-map-lat`/`-lon` are written 288 * by services-search.js from the position it is searching with; if that is 289 * the point this map sent, every render for as long as it stays there is an
290 * answer about the view the driver chose, and none of them may move it. 291 * The moment anything else moves the search â the radius, a picked town, a 292 * GPS fix, the ladder â the attributes stop matching and framing resumes by 293 * itself. 294 */ 295 function isOwnCentre() { 296 var host = container(); 297 298 if (!host || !state.mine) { 299 return false; 300 } 301 302 var lat = parseFloat(host.getAttribute('data-map-lat')); 303 var lon = parseFloat(host.getAttribute('data-map-lon')); 304 305 if (isNaN(lat) || isNaN(lon)) { 306 return false; 307 } 308 309 return Math.abs(lat - state.mine.lat) < SAME_POINT 310 && Math.abs(lon - state.mine.lon) < SAME_POINT; 311 } 312 313 /* 314 * Google calls this when its script finishes. It is global because that is 315 * the only handle the loader takes. 316 * 317 * THE PAGE DEFINES THIS NAME BEFORE THE LOADER RUNS, and this replaces that 318 * placeholder. Neither half is redundant: `async` and `defer` are not ordered 319 * against each other, so on a deployed host Google's CDN can answer before 320 * the origin does and the loader calls a name this file has not reached yet. 321 * The placeholder catches that; the check below the assignment catches the 322 * other order. See the comment beside the script tags in the search view. 323 */ 324 /* 325 * HOW LONG THE MAP IS GIVEN BEFORE THE COLUMN SPEAKS FOR IT. 326 * 327 * Long enough that a slow connection is not called a blocked one â the API 328 * routinely takes several seconds on a phone â and short enough that nobody 329 * sits in front of a blank rectangle wondering whether the page is broken. 330 */ 331 var ARRIVAL_MS = 6000; 332 333 /* 334 * SAY WHAT IS MISSING, RATHER THAN SHOWING NOTHING. 335 * 336 * Confluence CT/4474830867 item 49. The consent blocker holds back any 337 * uncategorised third-party script, `maps.googleapis.com` among them, so a 338 * reader who declines non-essential cookies reaches this page with the live 339 * branch rendered and nothing to fill it: an empty canvas under a 340 * "Radius · N mi" caption labelling a circle that was never drawn. 341 * 342 * The caption goes with the map it annotates â a radius is a distance from a 343 * centre, and there is no centre on screen to measure from. The copy and the 344 * consent link live in the view; this only decides when they are shown. 345 * 346 * Reversible on purpose: the library may simply have been slow, and arriving 347 * late is not the same as never arriving. tpServicesMapReady() calls this 348 * with `false` before it builds anything. 349 */ 350 function showBlocked(show) { 351 var host = container(); 352 353 if (!host) { 354 return; 355 } 356 357 var note = host.querySelector('[data-services-map-blocked]'); 358 var caption = host.querySelector('[data-services-map-caption]'); 359 360 if (note) { 361 note.hidden = !show; 362 } 363 364 // NOT UNHIDDEN HERE WHEN THE MAP COMES BACK. The caption has its own rule â 365 // it is shown only for a search that carried a position â and this function 366 // knows nothing about that. Hiding is the only direction it is entitled to. 367 if (caption && show) { 368 caption.hidden = true; 369 } 370 } 371 372 window.tpServicesMapReady = function () { 373 var host = container(); 374 var canvas = host && host.querySelector('[data-services-map-canvas]'); 375 376 if (!canvas || !window.google || !window.google.maps) { 377 return; 378 } 379 380 // It arrived. Whatever the timeout may have concluded, it was wrong. 381 showBlocked(false); 382 383 state.map = new window.google.maps.Map(canvas, { 384 center: { 385 lat: parseFloat(host.getAttribute('data-map-lat')), 386 lng: parseFloat(host.getAttribute('data-map-lon')) 387 }, 388 zoom: parseInt(host.getAttribute('data-map-zoom'), 10), 389 390 // A driver is reading this for where things are relative to a road, not 391 // for points of interest we did not put there. Business POIs and transit 392 // are noise against our own markers. 393 disableDefaultUI: true, 394 zoomControl: true, 395 fullscreenControl: true, 396 397 /* 398 * THE RING IS NOT A SCALE, AND WITHOUT THIS NOTHING ELSE IS EITHER. 399 * 400 * render() calls fitBounds() on a bounds that always includes the ring, 401 * so the circle is framed to the viewport on every answer. That is
401what 402 * makes narrowing the radius visibly narrow the map â but it also means 403 * the circle occupies roughly the same share of the screen at ten miles 404 * as at eighty. The only thing that changed is the zoom, and with every 405 * default control switched off above there was nothing on screen that 406 * said so. 407 * 408 * Read as a measurement it is worse than unhelpful: a fifty-mile ring 409 * over a metro whose neighbours sit sixty miles out looks like it covers 410 * half a province, and there is no way to check it against anything. A 411 * scale bar is the check â it is the one control here that states a 412 * distance rather than implying one. 413 */ 414 scaleControl: true, 415 clickableIcons: false, 416 styles: [ 417 { featureType: 'poi', stylers: [{ visibility: 'off' }] }, 418 { featureType: 'transit', stylers: [{ visibility: 'off' }] } 419 ] 420 }); 421 422 state.ready = true; 423 render(); 424 425 /* 426 * DRAGGING THE MAP MOVES THE SEARCH â CMO-25 §3.02, 2026-09-24. 427 * 428 * Until now this file never watched the camera at all: three listeners, all 429 * of them about the list. The map was draggable and always had been, so a 430 * driver could pull it across the state and arrive at an empty view with 431 * the old town's pins gone and nothing asked about the new one. 432 * 433 * `dragend`, NOT `idle`, AND THAT IS THE DESIGN RATHER THAN A PREFERENCE. 434 * `idle` also fires after the camera is moved from code â and this file 435 * moves it after every answer â so an `idle` listener needs a flag saying 436 * "that one was mine", and `fitBounds` does not fire `idle` at all when the 437 * camera does not actually change, which leaves the flag set and swallows 438 * the next real drag. `dragend` is only ever the user. It is also exactly 439 * the scope asked for: zooming does not re-search, because the radius is 440 * the Within control's to set and a zoom would ask the same question again. 441 * 442 * The centre is remembered before it is sent, because the answer comes back 443 * through the same `tp-services-query` that re-frames the map â see 444 * isOwnCentre(). 445 */ 446 state.map.addListener('dragend', function () { 447 var centre = state.map.getCenter(); 448 449 if (!centre) { 450 return; 451 } 452 453 var lat = centre.lat(); 454 var lon = centre.lng(); 455 456 state.mine = { lat: lat, lon: lon }; 457 458 /* 459 * THREE THINGS, IN THIS ORDER â page owner, 2026-09-24. 460 * 461 * 1. the name, and with it the box 462 * 2. the ring, around the point the map was left at 463 * 3. the results 464 * 465 * It used to be the other way round: the coordinates went in the box and 466 * the request went out at once, and the name replaced them whenever it 467 * arrived. That works, and it flickers â the box says two different 468 * things a few hundred milliseconds apart, and the second one is the 469 * answer to a question the driver never saw asked. 470 * 471 * The cost of this order is that a slow geocode delays the search, which 472 * is why nameCentre() has a deadline rather than a promise to wait on. 473 */ 474 nameCentre(lat, lon, function (label) { 475 document.dispatchEvent(new CustomEvent('tp-services-centre', { 476 detail: { lat: lat, lon: lon, label: label } 477 })); 478 479 moveRing(lat, lon); 480 }); 481 }); 482 483 // The list owns visibility; redraw whenever it changes. 484 document.addEventListener('tp-services-query', render); 485 document.addEventListener('tp-services-open', function (event) { 486 highlight(event.detail && event.detail.id); 487 }); 488 }; 489 490 /* 491 * THE OTHER ORDER: Google got here first and called the placeholder, which 492 * did nothing but write this flag down. The real callback exists as of the 493 * line above, so run it â the loader will not call it a second time. 494 * 495 * Guarded on `google.maps` as well as the flag, because a placeholder that 496 * ran is not proof the library finished loading; the callback itself checks 497 * the same thing and returns quietly if it has not. 498 */ 499 if (window.tpServicesMapArrived && window.google && window.google.maps) { 500 window.tpServicesMapReady(); 501 } 502 503 /* 504 * AND IF NEITHER ORDER EVER HAPPENS, the column says so instead of staying 505 * blank. Armed only where there is a live map to wait for: the static branch 506 * has its own picture and nothing to wait on. 507 * 508 * `state.map` is the test rather than the flag, because it is the thing a 509 * reader can see. A loader that answered but could not build â no canvas, a 510 * key rejected â leaves the same empty rectangle as one that never ran. 511 */ 512 if (container()) { 513 window.setTimeout(function () { 514 if (!state.map) { 515 showBlocked(true); 516 } 517 }, ARRIVAL_MS); 518 } 519 520 /* 521 * THE SEARCH RADIUS, AS THE DESIGN SOURCE DRAWS IT. 522 * 523 * A translucent disc with a dashed edge. Google's Circle has no dash, so the 524 * edge is a Polyline of points computed around the centre and stroked with a 525 * repeating tick â the fill and the edge are two objects describing one 526 * circle, kept in step by being built together and never separately. 527 * 528 * Drawn only when the page carried a position. `data-map-radius` is absent 529 * without one, and a circle with no centre is a claim about a place nobody 530 * named. 531 */ 532 function drawRing(host) { 533 var miles = parseFloat(host.getAttribute('data-map-radius')); 534 var lat = parseFloat(host.getAttribute('data-map-lat')); 535 var lon = parseFloat(host.getAttribute('data-map-lon')); 536 537 clearRing(); 538 539 if (isNaN(miles) || isNaN(lat) || isNaN(lon)) { 540 return null; 541 } 542 543 var centre = new window.google.maps.LatLng(lat, lon); 544 var metres = miles * METRES_PER_MILE; 545 546 state.ring = new window.google.maps.Circle({ 547 map: state.map, 548 center: centre, 549 radius: metres, 550 fillColor: RING_COLOUR, 551 fillOpacity: RING_FILL_OPACITY, 552 // The edge is the polyline below; a second stroke here would double it. 553 strokeOpacity: 0, 554 clickable: false, 555 zIndex: 1 556 }); 557 558 // `geometry` is the one library config/maps.php asks Google for, and this 559 // is what it is asked for. 560 var spherical = window.google.maps.geometry && window.google.maps.geometry.spherical; 561 562 if (spherical) { 563 var edge = []; 564 565 for (var deg = 0; deg <= 360; deg += 6) { 566 edge.push(spherical.computeOffset(centre, metres, deg)); 567 } 568 569 state.ringEdge = new window.google.maps.Polyline({ 570 map: state.map, 571 path: edge, 572 strokeOpacity: 0, 573 clickable: false, 574 zIndex: 1, 575 icons: [{ 576 icon: { 577 path: 'M 0,-1 0,1', 578 strokeOpacity: RING_STROKE_OPACITY, 579 strokeColor: RING_COLOUR, 580 strokeWeight: 1.5, 581 scale: 3 582 }, 583 offset: '0', 584 repeat: '12px' 585 }] 586 }); 587 } 588 589 return state.ring.getBounds(); 590 } 591 592 function clearRing() { 593 if (state.ring) { 594 state.ring.setMap(null); 595 state.ring = null; 596 } 597 598 if (state.ringEdge) { 599 state.ringEdge.setMap(null); 600 state.ringEdge = null; 601 } 602 } 603 604 /* 605 * A PIN IS A DOM NODE, NOT A MARKER IMAGE. 606 * 607 * The design source gives the pin a hover: `transform-origin: 50% 100%`, 608 * `transform: scale(1.35)`, `transition: transform .15s ease`. A 609 * google.maps.Marker cannot have it. A Marker is one image composited onto 610 * the map â there is no element to put a CSS rule on, and the only way to 611 * grow one is to hand it a second, larger image on mouseover, which arrives 612 * in a single frame. The scale would be right and the easing would not 613 * exist, which is not the effect the comp describes. 614 * 615 * OverlayView gives back a real element, so the transition is the comp's 616 * own, written once in services.css and shared with the static map's pin. 617 * 618 * WHY NOT AdvancedMarkerElement, which is Google's modern answer to exactly 619 * this: it requires a Map ID issued from the Cloud Console, and a map is not
620 * allowed to quietly acquire an external configuration dependency. This 621 * needs neither a Map ID nor a library beyond the `geometry` that 622 * config/maps.php already asks for. 623 * 624 * Defined on first use because OverlayView does not exist until Google's 625 * script has run. 626 */ 627 var PinOverlay = null; 628 629 function definePinOverlay() { 630 if (PinOverlay) { 631 return PinOverlay; 632 } 633 634 PinOverlay = function (position, title, onClick) { 635 this.position_ = position; 636 this.title_ = title; 637 this.onClick_ = onClick; 638 this.node_ = null; 639 this.image_ = null; 640 this.icon_ = ''; 641 this.zIndex_ = 1; 642 }; 643 644 PinOverlay.prototype = new window.google.maps.OverlayView(); 645 646 PinOverlay.prototype.onAdd = function () { 647 var node = document.createElement('button'); 648 649 node.type = 'button'; 650 node.className = 'tp-services-map__pin'; 651 node.setAttribute('aria-label', this.title_); 652 node.style.zIndex = this.zIndex_; 653 654 var image = document.createElement('img'); 655 656 image.alt = ''; 657 image.setAttribute('aria-hidden', 'true'); 658 659 if (this.icon_) { 660 image.src = this.icon_; 661 } 662 663 node.appendChild(image); 664 node.addEventListener('click', this.onClick_); 665
666 this.node_ = node; 667 this.image_ = image; 668 669 // `overlayMouseTarget` is the pane that receives events; the panes that 670 // sit above it do not, and a pin that cannot be clicked is not a pin. 671 this.getPanes().overlayMouseTarget.appendChild(node); 672 }; 673 674 PinOverlay.prototype.draw = function () { 675 if (!this.node_) { 676 return; 677 } 678 679 var point = this.getProjection().fromLatLngToDivPixel(this.position_); 680 681 if (!point) { 682 return; 683 } 684 685 // Offset here rather than with a transform: the hover owns `transform`, 686 // and a second one on the same element would overwrite it. 687 this.node_.style.left = (point.x - PIN_TIP_X) + 'px'; 688 this.node_.style.top = (point.y - PIN_TIP_Y) + 'px'; 689 }; 690 691 PinOverlay.prototype.onRemove = function () { 692 if (this.node_ && this.node_.parentNode) { 693 this.node_.parentNode.removeChild(this.node_); 694 } 695 696 this.node_ = null; 697 this.image_ = null; 698 }; 699 700 PinOverlay.prototype.getPosition = function () { 701 return this.position_; 702 }; 703 704 PinOverlay.prototype.setIcon = function (url) { 705 this.icon_ = url; 706 707 if (this.image_) { 708 this.image_.src = url; 709 } 710 }; 711 712 PinOverlay.prototype.setZIndex = function (zIndex) { 713 this.zIndex_ = zIndex; 714 715 if (this.node_) { 716 this.node_.style.zIndex = zIndex; 717 } 718 }; 719 720 return PinOverlay; 721 } 722 723 function render() { 724 if (!state.ready) { 725 return; 726 } 727 728 var rows = Array.prototype.slice.call(document.querySelectorAll('[data-services-row]')); 729 var bounds = new window.google.maps.LatLngBounds(); 730 var shown = 0; 731 732 /* 733 * WHICH ROWS THIS ANSWER ACTUALLY HAS. 734 * 735 * The list is replaced outright on every search now, not filtered in 736 * place, so a row can leave the page rather than merely go hidden. A 737 * marker whose row is gone would otherwise sit on the map for the rest of 738 * the session â a pin for a shop that is not in the list beside it, which 739 * is worse than no pin at all. 740 */ 741 var present = {}; 742 743 rows.forEach(function (row) { 744 var id = row.getAttribute('data-services-row'); 745 present[id] = true; 746 var lat = parseFloat(row.getAttribute('data-lat')); 747 var lon = parseFloat(row.getAttribute('data-lon')); 748 749 if (isNaN(lat) || isNaN(lon)) { 750 return; 751 } 752 753 var visible = !row.hidden; 754 var marker = state.markers[id]; 755 756 if (!marker) { 757 var Overlay = definePinOverlay(); 758 759 marker = state.markers[id] = new Overlay( 760 new window.google.maps.LatLng(lat, lon), 761 row.getAttribute('data-name') || '', 762 function () { 763 // Tell the list; it decides what opening means. 764 document.dispatchEvent(new CustomEvent('tp-services-marker', { 765 detail: { id: id } 766 })); 767 } 768 ); 769 770 marker.setIcon(pin(PIN_COLOUR, '')); 771 } 772 773 /* 774 * Only when it changes. Marker tolerated being handed the same map on 775 * every answer; an OverlayView takes it as an instruction and rebuilds 776 * its node, which throws away a hover the cursor is in the middle of. 777 */ 778 var target = visible ? state.map : null; 779 780 if (marker.__onMap !== target) { 781 marker.__onMap = target; 782 marker.setMap(target); 783 } 784 785 if (visible) { 786 shown++; 787 marker.__rank = String(shown); 788 marker.__icon = row.getAttribute('data-icon') || ''; 789 paint(marker); 790 bounds.extend(marker.getPosition()); 791 } 792 }); 793 794 // Anything this answer did not include leaves the map with it. 795 Object.keys(state.markers).forEach(function (id) { 796 if (!present[id]) { 797 state.markers[id].setMap(null); 798 delete state.markers[id]; 799 } 800 }); 801 802 // THE RING IS PART OF THE VIEW, NOT AN OVERLAY ON IT. Framing the markers 803 // alone can leave the radius drawn off-screen, which turns the one thing 804 // that explains the result set into something nobody sees. 805 var ringBounds = drawRing(container()); 806 807 if (ringBounds) { 808 bounds.union(ringBounds); 809 } 810 811 /*
812 * Frame what is actually being shown, so narrowing the radius visibly 813 * narrows the map rather than leaving the view where it was. 814 * 815 * EXCEPT WHEN THIS ANSWER IS THE ONE THE DRAG ASKED FOR. A drag sends a new 816 * centre, the search comes back, and the answer arrives here as an ordinary 817 * `tp-services-query` â indistinguishable from any other, and re-framed 818 * like any other, which would throw the camera straight back to where the 819 * driver had just dragged it away from. See isOwnCentre() for why the test 820 * is the centre rather than a flag. 821 */ 822 if (!isOwnCentre()) { 823 if (shown > 1 || ringBounds) { 824 state.map.fitBounds(bounds, 48); 825 } else if (shown === 1) { 826 state.map.setCenter(bounds.getCenter()); 827 state.map.setZoom(13); 828 } 829 } 830 } 831 832 /* 833 * Draw one marker, in whichever of its two forms is available. 834 * 835 * The glyph is asked for once per category and painted in when it arrives; 836 * until then, and for a category this tree draws no icon for, the marker 837 * carries its rank instead. Both forms are legible on their own â the 838 * fallback is not a placeholder waiting to be replaced. 839 * 840 * ONE FORM, NOT TWO. A marker used to repaint itself in #011932 while its 841 * row was open. The design source draws no such state: a pin there is one 842 * pin, and opening a row is answered in the list, not by recolouring the 843 * map. The state was this file's own invention and is gone. 844 */ 845 function paint(marker) { 846 var colour = PIN_COLOUR; 847 var url = marker.__icon; 848 849 if (!url) { 850 marker.setIcon(pin(colour, marker.__rank || '')); 851 return; 852 } 853 854 if (glyphs[url]) { 855 // THE RANK TRAVELS WITH THE GLYPH. It used to be dropped here, because 856 // the glyph took the one space a filled pin had. The reference puts it in 857 // a badge on the pin's shoulder, so a marker now says both what kind of 858 // place it is and which row it is. 859 marker.setIcon(pin(colour, marker.__rank || '', glyphs[url])); 860 return; 861 } 862 863 marker.setIcon(pin(colour, marker.__rank || '')); 864 865 if (glyphs[url] === null) { 866 return; 867 } 868 869 glyphs[url] = null; 870 871 window.fetch(url, { credentials: 'same-origin' }).then(function (response) { 872 return response.ok ? response.text() : ''; 873 }).then(function (text) { 874 var inner = text 875 .replace(/^[\s\S]*?<svg[^>]*>/i, '') 876 .replace(/<\/svg>[\s\S]*$/i, '') 877 // The icons are drawn in one ink, as a stroke on some shapes and a fill 878 // on others. Swapped for `currentColor` so the ink is named once, by 879 // the group the pin puts them in, rather than twice here. 880 .replace(/#141A42/gi, 'currentColor') 881 882 // AND THE STROKE HAS TO SURVIVE THE SCALING. These are 36px icons 883 // drawn at 1.75, and the pin puts them in at 16 â which takes the line 884 // to 0.78px and leaves the glyph looking like wire. Scaled back up by 885 // the same factor the pin scales down by, so the weight on the marker 886 // is the weight they were drawn with. 887 .replace(/stroke-width="([\d.]+)"/g, function (all, width) { 888 return 'stroke-width="' + (parseFloat(width) / GLYPH_SCALE).toFixed(2) + '"'; 889 }); 890 891 if (!inner.trim()) { 892 return; 893 } 894 895 glyphs[url] = inner; 896 render(); 897 }).catch(function () { 898 /* The numbered pin is already on the map and stays. */ 899 }); 900 } 901 902 /* 903 * STACKING ONLY. The open row's marker comes to the front so it is not left 904 * underneath a neighbour the reader is not looking at. Nothing about it is 905 * drawn differently â see paint(). 906 */ 907 function highlight(id) { 908 Object.keys(state.markers).forEach(function (key) { 909 state.markers[key].setZIndex(key === id ? 10 : 1); 910 }); 911 } 912 913 /* 914 * The marker, drawn rather than fetched: one less request, and it can carry 915 * the row's rank so the map and the list read in the same order. 916 */ 917 function pin(colour, rank, glyph) { 918 /* 919 * THIS IS `search/map-pin.svg`, REDRAWN. The design source's marker is a 920 * file â a white outer teardrop with a drop shadow, a #0277AD body inside 921 * it, a white disc on the head and a 2px ring of the same ink around that 922 * disc. Google's Marker takes one image, so the file cannot simply be 923 * pointed at: the category glyph and the rank badge have to be composed 924 * into the same document. The paths below are the file's own, unchanged, 925 * so the silhouette and the ring are the asset's rather than a likeness 926 * of it drawn from memory. 927 * 928 * An earlier version here drew a plain white pin in the brand blue on the 929 * reasoning that a coloured ring competed with the glyph. That was written 930 * against a recollection of the reference, not the file: `map-pin.svg` has 931 * the ring, and it has the coloured body under it. 932 * 933 * GEOMETRY, in the asset's own 34x44 space. Head centred at (17, 17), tip 934 * at (17, 40); the white disc is r=10 once the ring covers 10..12; the 935 * glyph is the 36x36 category icon at 16, centred on the head; the badge 936 * is r=9 at (28, 6), which is the reference's `top:-3px; right:-3px`. 937 * 938 * THE CANVAS IS BIGGER THAN THE PIN. The badge and both shadows sit 939 * outside the 34x44 box, so the viewBox opens out to -4,-8 and the anch
939or 940 * moves with it: the tip lands at (21, 48) in the exported image, and that 941 * is the point Google puts on the coordinate. 942 */ 943 var head = glyph 944 // `fill="none"` is for the stroked icons, whose paths carry no fill of 945 // their own; the filled ones override it with their own attribute. 946 // `color` is what the swapped-in `currentColor` resolves against. 947 ? '<g transform="translate(9 9) scale(' + GLYPH_SCALE + ')" fill="none"' 948 + ' style="color:' + colour + '">' + glyph + '</g>' 949 : ''; 950 951 // Without a glyph the head carries the number instead, and the badge would 952 // then say it twice. 13px, not the 15 a filled head took: this number sits 953 // inside the r=10 white disc rather than on the whole head. 954 var centreText = glyph 955 ? '' 956 : '<text x="17" y="21.5" text-anchor="middle" font-family="Poppins, sans-serif"' 957 + ' font-size="13" font-weight="700" fill="' + colour + '">' + rank + '</text>'; 958 959 /* 960 * The reference's badge carries a shadow and no stroke â it is lifted off 961 * the pin rather than cut out of it. Poppins, not the reference's Reddit 962 * Sans: that face is loaded by the prototype and by nothing on this site, 963 * so asking for it here would render the fallback anyway. 964 */ 965 var badge = (glyph && rank) 966 ? '<circle cx="28" cy="6" r="9" fill="' + colour + '" filter="url(#tp-pin-badge)"/>' 967 + '<text x="28" y="10" text-anchor="middle" font-family="Poppins, sans-serif"' 968 + ' font-size="11" font-weight="700" fill="#ffffff">' + rank + '</text>' 969 : ''; 970 971 var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="48" height="56"' 972 + ' viewBox="-4 -8 48 56">' 973 + '<defs>' 974 // The asset's own shadow: dy 1, sigma 1.5, black at .3. The region is 975 // opened past the file's tight Figma box so the tail below the tip is 976 // not cut off. 977 + '<filter id="tp-pin-body" x="-2" y="-2" width="38" height="50"' 978 + ' filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">' 979 + '<feDropShadow dx="0" dy="1" stdDeviation="1.5" flood-color="#000000"' 980 + ' flood-opacity="0.3"/></filter>' 981 // `0 2px 6px rgba(1,25,50,.25)` â a CSS blur radius is two sigma. 982 + '<filter id="tp-pin-badge" x="8" y="-12" width="40" height="42"' 983 + ' filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">' 984 + '<feDropShadow dx="0" dy="2" stdDeviation="3" flood-color="#011932"' 985 + ' flood-opacity="0.25"/></filter>' 986 + '</defs>' 987 + '<path fill-rule="evenodd" clip-rule="evenodd" d="M3 16.8133C3 27.1733 17 40' 988 + ' 17 40C17 40 31 27.1733 31 16.8133C31 9.17653 24.74 3 17 3C9.26 3 3 9.17653 3' 989 + ' 16.8133Z" fill="#ffffff" filter="url(#tp-pin-body)"/>' 990 + '<path fill-rule="evenodd" clip-rule="evenodd" d="M5 16.9467C5 25.9067 17 37 17' 991 + ' 37C17 37 29 25.9067 29 16.9467C29 10.3419 23.6343 5 17 5C10.3657 5 5 10.3419 5' 992 + ' 16.9467Z" fill="' + colour + '"/>' 993 + '<circle cx="17" cy="17" r="11" fill="#ffffff"/>' 994 + '<path fill-rule="evenodd" clip-rule="evenodd" d="M17 5C23.6274 5 29 10.3726 29' 995 + ' 17C29 23.6274 23.6274 29 17 29C10.3726 29 5 23.6274 5 17C5 10.3726 10.3726 5 17' 996 + ' 5ZM17 7C11.4772 7 7 11.4772 7 17C7 22.5228 11.4772 27 17 27C22.5228 27 27' 997 + ' 22.5228 27 17C27 11.4772 22.5228 7 17 7Z" fill="' + colour + '"/>' 998 + head 999 + centreText 1000 + badge 1001 + '</svg>'; 1002 1003 /* 1004 * Just the document. The 48x56 box and the -4,-8 offset that puts the pin 1005 * box inside it are the stylesheet's, next to the hover that shares them. 1006 */ 1007 return 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(svg); 1008 } 1009})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.