PageSourceSearch

https://truckerpath.com/assets/services/services-map.js?v=377-9481db8a353d

js truckerpath.com collected 2026-10-01 12:01:51 UTC 38,168 bytes, 1,009 lines download raw bytes

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.