Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // ==UserScript==
- // @name Location History Leaflet Map
- // @namespace local
- // @version 1.3
- // @description Maps locationPopup city links in reverse order
- // @namespace http://www.cdn-money.ca/
- // @match http://www.cdn-money.ca/*
- // @require https://unpkg.com/[email protected]/dist/leaflet.js
- // @resource LEAFLET_CSS https://unpkg.com/[email protected]/dist/leaflet.css
- // @grant GM_getResourceText
- // @grant GM_addStyle
- // @grant GM_xmlhttpRequest
- // @grant GM_getValue
- // @grant GM_setValue
- // @connect nominatim.openstreetmap.org
- // @run-at document-idle
- // ==/UserScript==
- (() => {
- "use strict";
- GM_addStyle(GM_getResourceText("LEAFLET_CSS"));
- GM_addStyle(`
- .location-number-marker {
- background: transparent;
- border: none;
- }
- .location-number-marker div {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 28px;
- height: 28px;
- border: 2px solid #222;
- border-radius: 50%;
- background: white;
- color: #111;
- font-size: 14px;
- font-weight: bold;
- box-shadow: 0 1px 4px rgba(0, 0, 0, 0.45);
- }
- `);
- let leafletMap = null;
- let currentMapElement = null;
- let routeLayer = null;
- let processing = false;
- let lastLocationSignature = "";
- const PROVINCE_NAMES = {
- AB: "Alberta",
- BC: "British Columbia",
- MB: "Manitoba",
- NB: "New Brunswick",
- NL: "Newfoundland and Labrador",
- NS: "Nova Scotia",
- NT: "Northwest Territories",
- NU: "Nunavut",
- ON: "Ontario",
- PE: "Prince Edward Island",
- QC: "Quebec",
- SK: "Saskatchewan",
- YT: "Yukon"
- };
- function sleep(milliseconds) {
- return new Promise(resolve => {
- setTimeout(resolve, milliseconds);
- });
- }
- function normalizeText(value) {
- return value
- .trim()
- .replace(/\s+/g, " ");
- }
- function normalizeForComparison(value) {
- return normalizeText(value)
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "")
- .toLowerCase()
- .replace(/[.'’()-]/g, " ")
- .replace(/\s+/g, " ")
- .trim();
- }
- function parseLocation(locationText) {
- const commaIndex = locationText.lastIndexOf(",");
- if (commaIndex === -1) {
- throw new Error(
- `Location is not in "City, Province" format: ${locationText}`
- );
- }
- const city = normalizeText(
- locationText.slice(0, commaIndex)
- );
- const provinceInput = normalizeText(
- locationText.slice(commaIndex + 1)
- );
- const provinceCode = provinceInput
- .replace(/\./g, "")
- .toUpperCase();
- const province =
- PROVINCE_NAMES[provinceCode] ??
- provinceInput;
- return {
- original: locationText,
- city,
- province,
- provinceCode,
- // Accent-free values used for searching and matching.
- searchCity: normalizeForComparison(city),
- searchProvince: normalizeForComparison(province)
- };
- }
- function flattenAccents(value) {
- return String(value)
- .normalize("NFD")
- .replace(/[\u0300-\u036f]/g, "");
- }
- function scrapeLocationNames() {
- const links = [
- ...document.querySelectorAll(
- 'a[href*="javascript:locationPopup"]'
- )
- ];
- const names = links
- .map(link => normalizeText(link.textContent))
- .filter(Boolean)
- .reverse();
- console.log(
- "[Location Map] Reversed location list:",
- names
- );
- return names;
- }
- function gmRequest(details) {
- return new Promise((resolve, reject) => {
- GM_xmlhttpRequest({
- ...details,
- onload(response) {
- resolve(response);
- },
- onerror(error) {
- reject(error);
- },
- ontimeout() {
- reject(
- new Error("Geocoding request timed out")
- );
- }
- });
- });
- }
- function getCandidateNames(candidate) {
- const address = candidate.address ?? {};
- const namedetails = candidate.namedetails ?? {};
- return [
- candidate.name,
- address.city,
- address.town,
- address.village,
- address.hamlet,
- address.municipality,
- address.borough,
- address.suburb,
- address.quarter,
- address.neighbourhood,
- address.city_district,
- address.county,
- ...Object.values(namedetails)
- ]
- .filter(value => typeof value === "string")
- .map(normalizeForComparison)
- .filter(Boolean);
- }
- function candidateHasExactCityName(candidate, requestedCity) {
- const target = normalizeForComparison(requestedCity);
- const candidateNames = getCandidateNames(candidate);
- return candidateNames.includes(target);
- }
- function candidateHasExpandedCityName(candidate, requestedCity) {
- const target = normalizeForComparison(requestedCity);
- const candidateNames = getCandidateNames(candidate);
- return candidateNames.some(name => {
- if (name === target) {
- return true;
- }
- /*
- * Allow:
- * Napanee -> Greater Napanee
- * Sudbury -> Greater Sudbury
- *
- * But do not allow arbitrary partial-word matches.
- */
- const targetWords = target.split(" ");
- const nameWords = name.split(" ");
- if (targetWords.length > nameWords.length) {
- return false;
- }
- for (
- let start = 0;
- start <= nameWords.length - targetWords.length;
- start++
- ) {
- const section = nameWords.slice(
- start,
- start + targetWords.length
- );
- if (section.join(" ") === target) {
- return true;
- }
- }
- return false;
- });
- }
- function candidateMatchesRequestedCity(candidate, requestedCity) {
- const target = normalizeForComparison(requestedCity);
- const candidateNames = getCandidateNames(candidate);
- return candidateNames.some(name => {
- return name === target;
- });
- }
- function candidateMatchesProvince(candidate, requestedProvince) {
- const address = candidate.address ?? {};
- const returnedProvince =
- address.state ??
- address.province ??
- address.region ??
- "";
- return (
- normalizeForComparison(returnedProvince) ===
- normalizeForComparison(requestedProvince)
- );
- }
- function scoreCandidate(candidate, parsedLocation) {
- let score = 0;
- const address = candidate.address ?? {};
- const candidateNames = getCandidateNames(candidate);
- const requestedCity =
- normalizeForComparison(parsedLocation.city);
- if (candidateNames.includes(requestedCity)) {
- score += 100;
- }
- if (
- normalizeForComparison(candidate.name ?? "") ===
- requestedCity
- ) {
- score += 50;
- }
- if (candidateMatchesProvince(
- candidate,
- parsedLocation.province
- )) {
- score += 30;
- }
- if (
- (address.country_code ?? "").toLowerCase() === "ca"
- ) {
- score += 20;
- }
- const preferredTypes = new Set([
- "city",
- "town",
- "village",
- "municipality",
- "borough",
- "suburb",
- "quarter",
- "neighbourhood",
- "city_district",
- "administrative"
- ]);
- if (
- preferredTypes.has(candidate.addresstype) ||
- preferredTypes.has(candidate.type)
- ) {
- score += 10;
- }
- return score;
- }
- async function performGeocodeRequest(parameters) {
- const url =
- "https://nominatim.openstreetmap.org/search?" +
- parameters.toString();
- const response = await gmRequest({
- method: "GET",
- url,
- headers: {
- Accept: "application/json"
- },
- timeout: 20000
- });
- if (response.status !== 200) {
- throw new Error(
- `Nominatim returned HTTP ${response.status}`
- );
- }
- return JSON.parse(response.responseText);
- }
- async function geocodeLocation(locationText) {
- const parsed = parseLocation(locationText);
- const cacheKey =
- "location-geocode-v4:" +
- normalizeForComparison(parsed.original);
- const cached = await GM_getValue(cacheKey, null);
- if (cached) {
- console.log(
- `[Geocoder] Cache hit for ${locationText}`,
- cached
- );
- return cached;
- }
- /*
- * First use the exact local/postal city name as a free-form query.
- *
- * This is important for former municipalities such as:
- * East York, Nepean, Vanier, Charlesbourg and Verdun.
- */
- const exactParameters = new URLSearchParams({
- q: `${flattenAccents(parsed.city)}, ${flattenAccents(parsed.province)}, Canada`,
- format: "jsonv2",
- addressdetails: "1",
- namedetails: "1",
- countrycodes: "ca",
- limit: "10"
- });
- let candidates =
- await performGeocodeRequest(exactParameters);
- let matchingCandidates = candidates.filter(candidate => {
- return (
- candidateMatchesRequestedCity(
- candidate,
- parsed.city
- ) &&
- candidateMatchesProvince(
- candidate,
- parsed.province
- )
- );
- });
- /*
- * Try a structured request only when the exact-name request
- * did not produce a matching local place.
- */
- if (matchingCandidates.length === 0) {
- await sleep(1100);
- const structuredParameters = new URLSearchParams({
- city: flattenAccents(parsed.city),
- state: flattenAccents(parsed.province),
- country: "Canada",
- countrycodes: "ca",
- format: "jsonv2",
- addressdetails: "1",
- namedetails: "1",
- limit: "10"
- });
- candidates =
- await performGeocodeRequest(
- structuredParameters
- );
- matchingCandidates = candidates.filter(candidate => {
- return (
- candidateMatchesRequestedCity(
- candidate,
- parsed.city
- ) &&
- candidateMatchesProvince(
- candidate,
- parsed.province
- )
- );
- });
- }
- /*
- * Do not silently turn East York into Toronto or Nepean
- * into Ottawa. A result must preserve the requested place
- * name somewhere in its OSM name/address fields.
- */
- if (matchingCandidates.length === 0) {
- throw new Error(
- `No exact local-place match found for ${locationText}`
- );
- }
- matchingCandidates.sort((first, second) => {
- return (
- scoreCandidate(second, parsed) -
- scoreCandidate(first, parsed)
- );
- });
- const best = matchingCandidates[0];
- const result = {
- location: parsed.original,
- city: parsed.city,
- province: parsed.province,
- latitude: Number(best.lat),
- longitude: Number(best.lon),
- displayName: best.display_name,
- osmType: best.osm_type,
- osmId: best.osm_id,
- resultType: best.addresstype ?? best.type
- };
- await GM_setValue(cacheKey, result);
- console.log(
- `[Geocoder] Resolved ${locationText}`,
- result
- );
- return result;
- }
- function createNumberIcon(number) {
- return L.divIcon({
- className: "location-number-marker",
- html: `<div>${number}</div>`,
- iconSize: [28, 28],
- iconAnchor: [14, 14],
- popupAnchor: [0, -16]
- });
- }
- function drawLocations(locations) {
- if (!leafletMap) {
- return;
- }
- if (routeLayer) {
- routeLayer.remove();
- }
- routeLayer = L.layerGroup().addTo(leafletMap);
- const coordinates = [];
- locations.forEach((location, index) => {
- const latLng = [
- location.latitude,
- location.longitude
- ];
- coordinates.push(latLng);
- L.marker(latLng, {
- icon: createNumberIcon(index + 1)
- })
- .bindPopup(
- `<strong>${index + 1}. ${location.location}</strong>` +
- `<br>${location.displayName}`
- )
- .addTo(routeLayer);
- });
- if (coordinates.length >= 2) {
- L.polyline(coordinates, {
- weight: 3,
- opacity: 0.8
- }).addTo(routeLayer);
- }
- if (coordinates.length === 1) {
- leafletMap.setView(coordinates[0], 11);
- } else if (coordinates.length > 1) {
- leafletMap.fitBounds(
- L.latLngBounds(coordinates),
- {
- padding: [30, 30],
- maxZoom: 11
- }
- );
- }
- requestAnimationFrame(() => {
- leafletMap.invalidateSize();
- });
- }
- async function populateMap() {
- if (processing) {
- return;
- }
- const locationNames = scrapeLocationNames();
- const signature = JSON.stringify(locationNames);
- if (
- locationNames.length === 0 ||
- signature === lastLocationSignature
- ) {
- return;
- }
- processing = true;
- lastLocationSignature = signature;
- console.log(
- `[Location Map] Geocoding ${locationNames.length} locations`
- );
- const resolvedLocations = [];
- const failedLocations = [];
- try {
- for (let index = 0; index < locationNames.length; index++) {
- const locationName = locationNames[index];
- try {
- const result =
- await geocodeLocation(locationName);
- resolvedLocations.push(result);
- } catch (error) {
- console.error(
- `[Geocoder] Failed: ${locationName}`,
- error
- );
- failedLocations.push({
- location: locationName,
- error: error.message
- });
- }
- /*
- * Public Nominatim usage is limited to approximately
- * one request per second. Cached entries do not make
- * a network request, but waiting here keeps the script
- * safely below the limit.
- */
- if (index < locationNames.length - 1) {
- await sleep(1100);
- }
- }
- drawLocations(resolvedLocations);
- console.log(
- "[Location Map] Successfully mapped:",
- resolvedLocations
- );
- if (failedLocations.length > 0) {
- console.warn(
- "[Location Map] Locations not mapped:",
- failedLocations
- );
- }
- } finally {
- processing = false;
- }
- }
- function initializeMap(mapElement) {
- if (
- leafletMap &&
- currentMapElement === mapElement
- ) {
- populateMap();
- return;
- }
- if (leafletMap) {
- leafletMap.remove();
- leafletMap = null;
- routeLayer = null;
- }
- currentMapElement = mapElement;
- mapElement.style.width = "650px";
- mapElement.style.height = "350px";
- leafletMap = L.map(mapElement);
- L.tileLayer(
- "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
- {
- maxZoom: 19,
- attribution: "© OpenStreetMap contributors"
- }
- ).addTo(leafletMap);
- leafletMap.setView([56, -96], 4);
- requestAnimationFrame(() => {
- leafletMap.invalidateSize();
- });
- console.log(
- "[Leaflet] Map initialized:",
- mapElement
- );
- populateMap();
- }
- function detectMap() {
- const mapElement =
- document.querySelector("#map");
- if (!mapElement) {
- return;
- }
- initializeMap(mapElement);
- }
- detectMap();
- const pageObserver = new MutationObserver(() => {
- detectMap();
- });
- pageObserver.observe(document.documentElement, {
- childList: true,
- subtree: true,
- characterData: true,
- attributes: true,
- attributeFilter: ["href"]
- });
- })();
Advertisement
Add Comment
Please, Sign In to add comment