Guest User

Untitled

a guest
Aug 5th, 2026
25
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 18.54 KB | None | 0 0
  1. // ==UserScript==
  2. // @name Location History Leaflet Map
  3. // @namespace local
  4. // @version 1.3
  5. // @description Maps locationPopup city links in reverse order
  6. // @namespace http://www.cdn-money.ca/
  7. // @match http://www.cdn-money.ca/*
  8. // @require https://unpkg.com/[email protected]/dist/leaflet.js
  9. // @resource LEAFLET_CSS https://unpkg.com/[email protected]/dist/leaflet.css
  10. // @grant GM_getResourceText
  11. // @grant GM_addStyle
  12. // @grant GM_xmlhttpRequest
  13. // @grant GM_getValue
  14. // @grant GM_setValue
  15. // @connect nominatim.openstreetmap.org
  16. // @run-at document-idle
  17. // ==/UserScript==
  18.  
  19. (() => {
  20. "use strict";
  21.  
  22. GM_addStyle(GM_getResourceText("LEAFLET_CSS"));
  23.  
  24. GM_addStyle(`
  25. .location-number-marker {
  26. background: transparent;
  27. border: none;
  28. }
  29.  
  30. .location-number-marker div {
  31. display: flex;
  32. align-items: center;
  33. justify-content: center;
  34. width: 28px;
  35. height: 28px;
  36. border: 2px solid #222;
  37. border-radius: 50%;
  38. background: white;
  39. color: #111;
  40. font-size: 14px;
  41. font-weight: bold;
  42. box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
  43. }
  44. `);
  45.  
  46. let leafletMap = null;
  47. let currentMapElement = null;
  48. let routeLayer = null;
  49. let processing = false;
  50. let lastLocationSignature = "";
  51.  
  52. const PROVINCE_NAMES = {
  53. AB: "Alberta",
  54. BC: "British Columbia",
  55. MB: "Manitoba",
  56. NB: "New Brunswick",
  57. NL: "Newfoundland and Labrador",
  58. NS: "Nova Scotia",
  59. NT: "Northwest Territories",
  60. NU: "Nunavut",
  61. ON: "Ontario",
  62. PE: "Prince Edward Island",
  63. QC: "Quebec",
  64. SK: "Saskatchewan",
  65. YT: "Yukon"
  66. };
  67.  
  68. function sleep(milliseconds) {
  69. return new Promise(resolve => {
  70. setTimeout(resolve, milliseconds);
  71. });
  72. }
  73.  
  74. function normalizeText(value) {
  75. return value
  76. .trim()
  77. .replace(/\s+/g, " ");
  78. }
  79.  
  80. function normalizeForComparison(value) {
  81. return normalizeText(value)
  82. .normalize("NFD")
  83. .replace(/[\u0300-\u036f]/g, "")
  84. .toLowerCase()
  85. .replace(/[.'’()-]/g, " ")
  86. .replace(/\s+/g, " ")
  87. .trim();
  88. }
  89.  
  90. function parseLocation(locationText) {
  91. const commaIndex = locationText.lastIndexOf(",");
  92.  
  93. if (commaIndex === -1) {
  94. throw new Error(
  95. `Location is not in "City, Province" format: ${locationText}`
  96. );
  97. }
  98.  
  99. const city = normalizeText(
  100. locationText.slice(0, commaIndex)
  101. );
  102.  
  103. const provinceInput = normalizeText(
  104. locationText.slice(commaIndex + 1)
  105. );
  106.  
  107. const provinceCode = provinceInput
  108. .replace(/\./g, "")
  109. .toUpperCase();
  110.  
  111. const province =
  112. PROVINCE_NAMES[provinceCode] ??
  113. provinceInput;
  114.  
  115. return {
  116. original: locationText,
  117. city,
  118. province,
  119. provinceCode,
  120.  
  121. // Accent-free values used for searching and matching.
  122. searchCity: normalizeForComparison(city),
  123. searchProvince: normalizeForComparison(province)
  124. };
  125. }
  126.  
  127. function flattenAccents(value) {
  128. return String(value)
  129. .normalize("NFD")
  130. .replace(/[\u0300-\u036f]/g, "");
  131. }
  132.  
  133. function scrapeLocationNames() {
  134. const links = [
  135. ...document.querySelectorAll(
  136. 'a[href*="javascript:locationPopup"]'
  137. )
  138. ];
  139.  
  140. const names = links
  141. .map(link => normalizeText(link.textContent))
  142. .filter(Boolean)
  143. .reverse();
  144.  
  145. console.log(
  146. "[Location Map] Reversed location list:",
  147. names
  148. );
  149.  
  150. return names;
  151. }
  152.  
  153. function gmRequest(details) {
  154. return new Promise((resolve, reject) => {
  155. GM_xmlhttpRequest({
  156. ...details,
  157.  
  158. onload(response) {
  159. resolve(response);
  160. },
  161.  
  162. onerror(error) {
  163. reject(error);
  164. },
  165.  
  166. ontimeout() {
  167. reject(
  168. new Error("Geocoding request timed out")
  169. );
  170. }
  171. });
  172. });
  173. }
  174.  
  175. function getCandidateNames(candidate) {
  176. const address = candidate.address ?? {};
  177. const namedetails = candidate.namedetails ?? {};
  178.  
  179. return [
  180. candidate.name,
  181.  
  182. address.city,
  183. address.town,
  184. address.village,
  185. address.hamlet,
  186. address.municipality,
  187. address.borough,
  188. address.suburb,
  189. address.quarter,
  190. address.neighbourhood,
  191. address.city_district,
  192. address.county,
  193.  
  194. ...Object.values(namedetails)
  195. ]
  196. .filter(value => typeof value === "string")
  197. .map(normalizeForComparison)
  198. .filter(Boolean);
  199. }
  200.  
  201. function candidateHasExactCityName(candidate, requestedCity) {
  202. const target = normalizeForComparison(requestedCity);
  203. const candidateNames = getCandidateNames(candidate);
  204.  
  205. return candidateNames.includes(target);
  206. }
  207.  
  208. function candidateHasExpandedCityName(candidate, requestedCity) {
  209. const target = normalizeForComparison(requestedCity);
  210. const candidateNames = getCandidateNames(candidate);
  211.  
  212. return candidateNames.some(name => {
  213. if (name === target) {
  214. return true;
  215. }
  216.  
  217. /*
  218. * Allow:
  219. * Napanee -> Greater Napanee
  220. * Sudbury -> Greater Sudbury
  221. *
  222. * But do not allow arbitrary partial-word matches.
  223. */
  224. const targetWords = target.split(" ");
  225. const nameWords = name.split(" ");
  226.  
  227. if (targetWords.length > nameWords.length) {
  228. return false;
  229. }
  230.  
  231. for (
  232. let start = 0;
  233. start <= nameWords.length - targetWords.length;
  234. start++
  235. ) {
  236. const section = nameWords.slice(
  237. start,
  238. start + targetWords.length
  239. );
  240.  
  241. if (section.join(" ") === target) {
  242. return true;
  243. }
  244. }
  245.  
  246. return false;
  247. });
  248. }
  249.  
  250. function candidateMatchesRequestedCity(candidate, requestedCity) {
  251. const target = normalizeForComparison(requestedCity);
  252. const candidateNames = getCandidateNames(candidate);
  253.  
  254. return candidateNames.some(name => {
  255. return name === target;
  256. });
  257. }
  258.  
  259. function candidateMatchesProvince(candidate, requestedProvince) {
  260. const address = candidate.address ?? {};
  261.  
  262. const returnedProvince =
  263. address.state ??
  264. address.province ??
  265. address.region ??
  266. "";
  267.  
  268. return (
  269. normalizeForComparison(returnedProvince) ===
  270. normalizeForComparison(requestedProvince)
  271. );
  272. }
  273.  
  274. function scoreCandidate(candidate, parsedLocation) {
  275. let score = 0;
  276.  
  277. const address = candidate.address ?? {};
  278. const candidateNames = getCandidateNames(candidate);
  279. const requestedCity =
  280. normalizeForComparison(parsedLocation.city);
  281.  
  282. if (candidateNames.includes(requestedCity)) {
  283. score += 100;
  284. }
  285.  
  286. if (
  287. normalizeForComparison(candidate.name ?? "") ===
  288. requestedCity
  289. ) {
  290. score += 50;
  291. }
  292.  
  293. if (candidateMatchesProvince(
  294. candidate,
  295. parsedLocation.province
  296. )) {
  297. score += 30;
  298. }
  299.  
  300. if (
  301. (address.country_code ?? "").toLowerCase() === "ca"
  302. ) {
  303. score += 20;
  304. }
  305.  
  306. const preferredTypes = new Set([
  307. "city",
  308. "town",
  309. "village",
  310. "municipality",
  311. "borough",
  312. "suburb",
  313. "quarter",
  314. "neighbourhood",
  315. "city_district",
  316. "administrative"
  317. ]);
  318.  
  319. if (
  320. preferredTypes.has(candidate.addresstype) ||
  321. preferredTypes.has(candidate.type)
  322. ) {
  323. score += 10;
  324. }
  325.  
  326. return score;
  327. }
  328.  
  329. async function performGeocodeRequest(parameters) {
  330. const url =
  331. "https://nominatim.openstreetmap.org/search?" +
  332. parameters.toString();
  333.  
  334. const response = await gmRequest({
  335. method: "GET",
  336. url,
  337. headers: {
  338. Accept: "application/json"
  339. },
  340. timeout: 20000
  341. });
  342.  
  343. if (response.status !== 200) {
  344. throw new Error(
  345. `Nominatim returned HTTP ${response.status}`
  346. );
  347. }
  348.  
  349. return JSON.parse(response.responseText);
  350. }
  351.  
  352. async function geocodeLocation(locationText) {
  353. const parsed = parseLocation(locationText);
  354.  
  355. const cacheKey =
  356. "location-geocode-v4:" +
  357. normalizeForComparison(parsed.original);
  358.  
  359. const cached = await GM_getValue(cacheKey, null);
  360.  
  361. if (cached) {
  362. console.log(
  363. `[Geocoder] Cache hit for ${locationText}`,
  364. cached
  365. );
  366.  
  367. return cached;
  368. }
  369.  
  370. /*
  371. * First use the exact local/postal city name as a free-form query.
  372. *
  373. * This is important for former municipalities such as:
  374. * East York, Nepean, Vanier, Charlesbourg and Verdun.
  375. */
  376. const exactParameters = new URLSearchParams({
  377. q: `${flattenAccents(parsed.city)}, ${flattenAccents(parsed.province)}, Canada`,
  378. format: "jsonv2",
  379. addressdetails: "1",
  380. namedetails: "1",
  381. countrycodes: "ca",
  382. limit: "10"
  383. });
  384. let candidates =
  385. await performGeocodeRequest(exactParameters);
  386.  
  387. let matchingCandidates = candidates.filter(candidate => {
  388. return (
  389. candidateMatchesRequestedCity(
  390. candidate,
  391. parsed.city
  392. ) &&
  393. candidateMatchesProvince(
  394. candidate,
  395. parsed.province
  396. )
  397. );
  398. });
  399.  
  400. /*
  401. * Try a structured request only when the exact-name request
  402. * did not produce a matching local place.
  403. */
  404. if (matchingCandidates.length === 0) {
  405. await sleep(1100);
  406.  
  407. const structuredParameters = new URLSearchParams({
  408. city: flattenAccents(parsed.city),
  409. state: flattenAccents(parsed.province),
  410. country: "Canada",
  411. countrycodes: "ca",
  412. format: "jsonv2",
  413. addressdetails: "1",
  414. namedetails: "1",
  415. limit: "10"
  416. });
  417.  
  418. candidates =
  419. await performGeocodeRequest(
  420. structuredParameters
  421. );
  422.  
  423. matchingCandidates = candidates.filter(candidate => {
  424. return (
  425. candidateMatchesRequestedCity(
  426. candidate,
  427. parsed.city
  428. ) &&
  429. candidateMatchesProvince(
  430. candidate,
  431. parsed.province
  432. )
  433. );
  434. });
  435. }
  436.  
  437. /*
  438. * Do not silently turn East York into Toronto or Nepean
  439. * into Ottawa. A result must preserve the requested place
  440. * name somewhere in its OSM name/address fields.
  441. */
  442. if (matchingCandidates.length === 0) {
  443. throw new Error(
  444. `No exact local-place match found for ${locationText}`
  445. );
  446. }
  447.  
  448. matchingCandidates.sort((first, second) => {
  449. return (
  450. scoreCandidate(second, parsed) -
  451. scoreCandidate(first, parsed)
  452. );
  453. });
  454.  
  455. const best = matchingCandidates[0];
  456.  
  457. const result = {
  458. location: parsed.original,
  459. city: parsed.city,
  460. province: parsed.province,
  461. latitude: Number(best.lat),
  462. longitude: Number(best.lon),
  463. displayName: best.display_name,
  464. osmType: best.osm_type,
  465. osmId: best.osm_id,
  466. resultType: best.addresstype ?? best.type
  467. };
  468.  
  469. await GM_setValue(cacheKey, result);
  470.  
  471. console.log(
  472. `[Geocoder] Resolved ${locationText}`,
  473. result
  474. );
  475.  
  476. return result;
  477. }
  478.  
  479. function createNumberIcon(number) {
  480. return L.divIcon({
  481. className: "location-number-marker",
  482. html: `<div>${number}</div>`,
  483. iconSize: [28, 28],
  484. iconAnchor: [14, 14],
  485. popupAnchor: [0, -16]
  486. });
  487. }
  488.  
  489. function drawLocations(locations) {
  490. if (!leafletMap) {
  491. return;
  492. }
  493.  
  494. if (routeLayer) {
  495. routeLayer.remove();
  496. }
  497.  
  498. routeLayer = L.layerGroup().addTo(leafletMap);
  499.  
  500. const coordinates = [];
  501.  
  502. locations.forEach((location, index) => {
  503. const latLng = [
  504. location.latitude,
  505. location.longitude
  506. ];
  507.  
  508. coordinates.push(latLng);
  509.  
  510. L.marker(latLng, {
  511. icon: createNumberIcon(index + 1)
  512. })
  513. .bindPopup(
  514. `<strong>${index + 1}. ${location.location}</strong>` +
  515. `<br>${location.displayName}`
  516. )
  517. .addTo(routeLayer);
  518. });
  519.  
  520. if (coordinates.length >= 2) {
  521. L.polyline(coordinates, {
  522. weight: 3,
  523. opacity: 0.8
  524. }).addTo(routeLayer);
  525. }
  526.  
  527. if (coordinates.length === 1) {
  528. leafletMap.setView(coordinates[0], 11);
  529. } else if (coordinates.length > 1) {
  530. leafletMap.fitBounds(
  531. L.latLngBounds(coordinates),
  532. {
  533. padding: [30, 30],
  534. maxZoom: 11
  535. }
  536. );
  537. }
  538.  
  539. requestAnimationFrame(() => {
  540. leafletMap.invalidateSize();
  541. });
  542. }
  543.  
  544. async function populateMap() {
  545. if (processing) {
  546. return;
  547. }
  548.  
  549. const locationNames = scrapeLocationNames();
  550. const signature = JSON.stringify(locationNames);
  551.  
  552. if (
  553. locationNames.length === 0 ||
  554. signature === lastLocationSignature
  555. ) {
  556. return;
  557. }
  558.  
  559. processing = true;
  560. lastLocationSignature = signature;
  561.  
  562. console.log(
  563. `[Location Map] Geocoding ${locationNames.length} locations`
  564. );
  565.  
  566. const resolvedLocations = [];
  567. const failedLocations = [];
  568.  
  569. try {
  570. for (let index = 0; index < locationNames.length; index++) {
  571. const locationName = locationNames[index];
  572.  
  573. try {
  574. const result =
  575. await geocodeLocation(locationName);
  576.  
  577. resolvedLocations.push(result);
  578. } catch (error) {
  579. console.error(
  580. `[Geocoder] Failed: ${locationName}`,
  581. error
  582. );
  583.  
  584. failedLocations.push({
  585. location: locationName,
  586. error: error.message
  587. });
  588. }
  589.  
  590. /*
  591. * Public Nominatim usage is limited to approximately
  592. * one request per second. Cached entries do not make
  593. * a network request, but waiting here keeps the script
  594. * safely below the limit.
  595. */
  596. if (index < locationNames.length - 1) {
  597. await sleep(1100);
  598. }
  599. }
  600.  
  601. drawLocations(resolvedLocations);
  602.  
  603. console.log(
  604. "[Location Map] Successfully mapped:",
  605. resolvedLocations
  606. );
  607.  
  608. if (failedLocations.length > 0) {
  609. console.warn(
  610. "[Location Map] Locations not mapped:",
  611. failedLocations
  612. );
  613. }
  614. } finally {
  615. processing = false;
  616. }
  617. }
  618.  
  619. function initializeMap(mapElement) {
  620. if (
  621. leafletMap &&
  622. currentMapElement === mapElement
  623. ) {
  624. populateMap();
  625. return;
  626. }
  627.  
  628. if (leafletMap) {
  629. leafletMap.remove();
  630. leafletMap = null;
  631. routeLayer = null;
  632. }
  633.  
  634. currentMapElement = mapElement;
  635.  
  636. mapElement.style.width = "650px";
  637. mapElement.style.height = "350px";
  638.  
  639. leafletMap = L.map(mapElement);
  640.  
  641. L.tileLayer(
  642. "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
  643. {
  644. maxZoom: 19,
  645. attribution: "© OpenStreetMap contributors"
  646. }
  647. ).addTo(leafletMap);
  648.  
  649. leafletMap.setView([56, -96], 4);
  650.  
  651. requestAnimationFrame(() => {
  652. leafletMap.invalidateSize();
  653. });
  654.  
  655. console.log(
  656. "[Leaflet] Map initialized:",
  657. mapElement
  658. );
  659.  
  660. populateMap();
  661. }
  662.  
  663. function detectMap() {
  664. const mapElement =
  665. document.querySelector("#map");
  666.  
  667. if (!mapElement) {
  668. return;
  669. }
  670.  
  671. initializeMap(mapElement);
  672. }
  673.  
  674. detectMap();
  675.  
  676. const pageObserver = new MutationObserver(() => {
  677. detectMap();
  678. });
  679.  
  680. pageObserver.observe(document.documentElement, {
  681. childList: true,
  682. subtree: true,
  683. characterData: true,
  684. attributes: true,
  685. attributeFilter: ["href"]
  686. });
  687. })();
Advertisement
Add Comment
Please, Sign In to add comment