Waze LiveMap Options

Adds options to LiveMap to alter the Waze-suggested routes.

  1. // ==UserScript==
  2. // @name Waze LiveMap Options
  3. // @namespace WazeDev
  4. // @version 2019.07.06.001
  5. // @description Adds options to LiveMap to alter the Waze-suggested routes.
  6. // @author MapOMatic
  7. // @include /^https:\/\/www.waze.com\/.*livemap/
  8. // @contributionURL https://github.com/WazeDev/Thank-The-Authors
  9. // @require https://ajax.googleapis.com/ajax/libs/jquery/3.3.1/jquery.min.js
  10. // @license GNU GPL v3
  11. // @grant none
  12. // @noframes
  13. // ==/UserScript==
  14.  
  15. /* global W */
  16. /* global Node */
  17. /* global jQuery */
  18. /* global MutationObserver */
  19.  
  20. const $ = jQuery.noConflict(true);
  21. const EXPANDED_MAX_HEIGHT = '200px';
  22. const TRANS_TIME = '0.2s';
  23. const CSS = [
  24. '.lmo-options-header { padding-left: 27px; margin-top: 4px; cursor: pointer; color: #59899e; font-size: 11px; font-weight: 600; }',
  25. '.lmo-options-header i { margin-left: 5px; }',
  26. // eslint-disable-next-line max-len
  27. `.lmo-options-container { padding-left: 27px; padding-right: 27px; max-height: 500px; overflow: hidden; margin-bottom: 10px; transition: max-height ${TRANS_TIME}; -moz-transition: max-height ${TRANS_TIME}; -webkit-transition: max-height ${TRANS_TIME}; -o-transition: max-height ${TRANS_TIME}; }`,
  28. '.lmo-table { margin-top: 4px; font-size: 12px; border-collapse: collapse; }',
  29. '.lmo-table td { padding: 4px 10px 4px 10px; border: 1px solid #ddd; border-radius: 6px; }',
  30. '.lmo-table-header-text { margin: 0px; font-weight: 600; }',
  31. '.lmo-control-container { margin-right: 8px; }',
  32. '.lmo-control-container label { line-height: 18px; vertical-align: text-bottom; }',
  33. '.lmo-table input[type="checkbox"] { margin-right: 2px; }',
  34. '.lmo-table td.lmo-header-cell { padding-left: 4px; padding-right: 4px; }',
  35. '#lmo-header-avoid { color: #c55; }'
  36. ].join('\n');
  37.  
  38. let _fitBounds;
  39. const _settings = {
  40. 'lmo-tolls': { checked: false },
  41. 'lmo-freeways': { checked: false },
  42. 'lmo-ferries': { checked: false },
  43. 'lmo-difficult-turns': { checked: false },
  44. 'lmo-unpaved-roads': { checked: true },
  45. 'lmo-long-unpaved-roads': { checked: false },
  46. 'lmo-u-turns': { checked: false, opposite: true },
  47. 'lmo-hov': { checked: false, opposite: true },
  48. 'lmo-hide-traffic': { checked: false },
  49. 'lmo-vehicle-type': { value: 'private' },
  50. 'lmo-allow-all-passes': { checked: false },
  51. 'lmo-day': 'today',
  52. 'lmo-hour': 'now',
  53. collapsed: false
  54. };
  55.  
  56. function checked(id, optionalSetTo) {
  57. const $elem = $(`#${id}`);
  58. if (typeof optionalSetTo !== 'undefined') {
  59. $elem.prop('checked', optionalSetTo);
  60. return optionalSetTo;
  61. }
  62. return $elem.prop('checked');
  63. }
  64.  
  65. function getDateTimeOffset() {
  66. let hour = $('#lmo-hour').val();
  67. let day = $('#lmo-day').val();
  68. if (hour === '---') hour = 'now';
  69. if (day === '---') day = 'today';
  70. if (hour === '') hour = 'now';
  71. if (day === '') day = 'today';
  72.  
  73. const t = new Date();
  74. const thour = (t.getHours() * 60) + t.getMinutes();
  75. const tnow = (t.getDay() * 1440) + thour;
  76. let tsel = tnow;
  77.  
  78. if (hour === 'now') {
  79. if (day !== 'today') tsel = (parseInt(day, 10) * 1440) + thour;
  80. } else {
  81. tsel = (day === 'today' ? t.getDay() * 1440 : parseInt(day, 10) * 1440) + parseInt(hour, 10);
  82. }
  83.  
  84. let diff = tsel - tnow;
  85. if (diff < -3.5 * 1440) {
  86. diff += 7 * 1440;
  87. } else if (diff > 3.5 * 1440) {
  88. diff -= 7 * 1440;
  89. }
  90.  
  91. return diff;
  92. }
  93.  
  94. function getRouteTime(routeIdx) {
  95. let sec = W.app.routing.controller.store.state.routes[routeIdx].seconds;
  96. const hours = Math.floor(sec / 3600);
  97. sec -= hours * 3600;
  98. const min = Math.floor(sec / 60);
  99. sec -= min * 60;
  100. return `${(hours > 0 ? `${hours} h ` : '') + (min > 0 ? `${min} min ` : '') + sec} sec`;
  101. }
  102.  
  103. function updateTimes() {
  104. const $routeTimes = $('.wm-route-item__time');
  105. for (let idx = 0; idx < $routeTimes.length; idx++) {
  106. const time = getRouteTime(idx);
  107. const $routeTime = $routeTimes.eq(idx);
  108. const contents = $routeTime.contents();
  109. contents[contents.length - 1].remove();
  110. $routeTime.append(` ${time}`);
  111. }
  112. }
  113.  
  114. function fetchRoutes() {
  115. const { state } = W.app.routing.controller.store;
  116. // Does nothing if "from" and "to" haven't been specified yet.
  117. if (state && state.from && state.to) {
  118. // HACK - Temporarily remove the onAfterItemAdded function, to prevent map from moving.
  119. W.app.map.fitBounds = function fakeFitBounds() { };
  120.  
  121. // Trigger the route search.
  122. W.app.routing.controller.findRoutes();
  123. }
  124. }
  125.  
  126. function onOptionsHeaderClick() {
  127. const $container = $('.lmo-options-container');
  128. const collapsed = $container.css('max-height') === '0px';
  129. $('.lmo-options-header i').removeClass(collapsed ? 'fa-angle-down' : 'fa-angle-up').addClass(collapsed ? 'fa-angle-up' : 'fa-angle-down');
  130. $container.css({ maxHeight: collapsed ? EXPANDED_MAX_HEIGHT : '0px' });
  131. _settings.collapsed = !collapsed;
  132. }
  133.  
  134. function onControlChanged() {
  135. const { id } = this;
  136. if (id === 'lmo-hour' || id === 'lmo-day') {
  137. fetchRoutes();
  138. } else {
  139. const isChecked = checked(id);
  140. if (this.type === 'checkbox') {
  141. _settings[id].checked = isChecked;
  142. if (id === 'lmo-hide-traffic') {
  143. if (isChecked) {
  144. W.app.geoRssLayer._jamsLayer.remove();
  145. } else {
  146. W.app.geoRssLayer._jamsLayer.addTo(W.app.map);
  147. }
  148. } else {
  149. if (id === 'lmo-long-unpaved-roads') {
  150. if (isChecked) {
  151. checked('lmo-unpaved-roads', false);
  152. _settings['lmo-unpaved-roads'].checked = false;
  153. }
  154. } else if (id === 'lmo-unpaved-roads') {
  155. if (isChecked) {
  156. checked('lmo-long-unpaved-roads', false);
  157. _settings['lmo-long-unpaved-roads'].checked = false;
  158. }
  159. }
  160. fetchRoutes();
  161. }
  162. } else if (this.type === 'radio') {
  163. _settings['lmo-vehicle-type'].value = this.value;
  164. fetchRoutes();
  165. }
  166. }
  167. }
  168.  
  169. function addOptions() {
  170. if (!$('#lmo-table').length) {
  171. $('.wm-routing__top').after(
  172. $('<div>', { class: 'lmo-options-header' }).append(
  173. $('<span>').text('Change routing options'),
  174. $('<i>', { class: 'fa fa.fa-angle-down fa.fa-angle-up' }).addClass(_settings.collapsed ? 'fa-angle-down' : 'fa-angle-up')
  175. ),
  176. $('<div>', { class: 'lmo-options-container' }).css({ maxHeight: _settings.collapsed ? '0px' : EXPANDED_MAX_HEIGHT }).append(
  177. $('<table>', { class: 'lmo-table' }).append(
  178. [
  179. ['Avoid:', ['Tolls', 'Freeways', 'Ferries', 'HOV', 'Unpaved roads', 'Long unpaved roads', 'Difficult turns', 'U-Turns']],
  180. ['Vehicle Type:', ['Private', 'Taxi', 'Motorcycle']],
  181. ['Passes:', ['Allow all passes']],
  182. ['Options:', ['Hide traffic']]
  183. ].map(rowItems => {
  184. const rowID = rowItems[0].toLowerCase().replace(/[:]/g, '').replace(/ /g, '-');
  185. return $('<tr>', { id: `lmo-row-${rowID}` }).append(
  186. $('<td>', { class: 'lmo-header-cell' }).append(
  187. $('<span>', { id: `lmo-header-${rowID}`, class: 'lmo-table-header-text' }).text(rowItems[0])
  188. ),
  189. $('<td>', { class: 'lmo-settings-cell' }).append(
  190. rowItems[1].map(text => {
  191. const idName = text.toLowerCase().replace(/ /g, '-');
  192. const id = `lmo-${idName}`;
  193. if (rowID === 'vehicle-type') {
  194. return $('<span>', { class: 'lmo-control-container' }).append(
  195. $('<input>', {
  196. id,
  197. type: 'radio',
  198. class: 'lmo-control',
  199. name: 'lmo-vehicle-type',
  200. value: idName.toLowerCase()
  201. }).prop('checked', _settings['lmo-vehicle-type'].value === idName.toLowerCase()), $('<label>', { for: id }).text(text)
  202. );
  203. }
  204. return $('<span>', { class: 'lmo-control-container' }).append(
  205. $('<input>', { id, type: 'checkbox', class: 'lmo-control' })
  206. .prop('checked', _settings[id].checked), $('<label>', { for: id }).text(text)
  207. );
  208. })
  209. )
  210. );
  211. })
  212. )
  213. )
  214. );
  215.  
  216. $('label[for="lmo-u-turns"').attr('title', 'Note: this is not an available setting in the app');
  217.  
  218. const timeArray = [['Now', 'now']];
  219. for (let i = 0; i < 48; i++) {
  220. const t = i * 30;
  221. const min = t % 60;
  222. const hr = Math.floor(t / 60);
  223. const str = `${(hr < 10 ? ('0') : '') + hr}:${min === 0 ? '00' : min}`;
  224. timeArray.push([str, t.toString()]);
  225. }
  226. $('#lmo-row-options td.lmo-settings-cell').append(
  227. $('<div>', { class: 'lmo-date-time' }).append(
  228. $('<label>', { for: 'lmo-day', style: 'font-weight: normal;' }).text('Day'),
  229. $('<select>', { id: 'lmo-day', class: 'lmo-control', style: 'margin-left: 4px; margin-right: 8px; padding: 0px; height: 22px;' }).append(
  230. [
  231. ['Today', 'today'],
  232. ['Monday', '1'],
  233. ['Tuesday', '2'],
  234. ['Wednesday', '3'],
  235. ['Thursday', '4'],
  236. ['Friday', '5'],
  237. ['Saturday', '6'],
  238. ['Sunday', '0']
  239. ].map(val => $('<option>', { value: val[1] }).text(val[0]))
  240. ),
  241. $('<label>', { for: 'lmo-hour', style: 'font-weight: normal;' }).text('Time'),
  242. $('<select>', { id: 'lmo-hour', class: 'lmo-control', style: 'margin-left: 4px; margin-right: 8px; padding: 0px; height: 22px;' }).append(
  243. timeArray.map(val => $('<option>', { value: val[1] }).text(val[0]))
  244. )
  245. )
  246. );
  247.  
  248. // Set up events
  249. $('.lmo-options-header').click(onOptionsHeaderClick);
  250. $('.lmo-control').change(onControlChanged);
  251. }
  252. }
  253.  
  254. function installHttpRequestInterceptor() {
  255. // Special thanks to Twister-UK for finding this code example...
  256. // original code from https://stackoverflow.com/questions/42578452/can-one-use-the-fetch-api-as-a-request-interceptor
  257. // eslint-disable-next-line wrap-iife
  258. window.fetch = (function fakeFetch(origFetch) {
  259. return function myFetch(...args) {
  260. let url = args[0];
  261. if (url.indexOf('/routingRequest?') !== -1) {
  262. // Remove all options from the request (everything after '&options=')
  263. let baseData = url.replace(url.match(/&options=(.*)/)[1], '');
  264. // recover stuff after the &options bit...
  265. const otherBits = `&returnGeometries${url.split('&returnGeometries')[1]}`;
  266. const options = [];
  267. [
  268. ['tolls', 'AVOID_TOLL_ROADS'],
  269. ['freeways', 'AVOID_PRIMARIES'],
  270. ['ferries', 'AVOID_FERRIES'],
  271. ['difficult-turns', 'AVOID_DANGEROUS_TURNS'],
  272. ['u-turns', 'ALLOW_UTURNS'],
  273. ['hov', 'ADD_HOV_ROUTES']
  274. ].forEach(optionInfo => {
  275. const id = `lmo-${optionInfo[0]}`;
  276. let enableOption = checked(id);
  277. if (_settings[id].opposite) enableOption = !enableOption;
  278. options.push(`${optionInfo[1]}:${enableOption ? 't' : 'f'}`);
  279. });
  280. if (checked('lmo-long-unpaved-roads')) {
  281. options.push('AVOID_LONG_TRAILS:t');
  282. } else if (checked('lmo-unpaved-roads')) {
  283. options.push('AVOID_TRAILS:t');
  284. } else {
  285. options.push('AVOID_LONG_TRAILS:f');
  286. }
  287. baseData = baseData.replace(/\?at=0/, `?at=${getDateTimeOffset()}`);
  288. url = baseData + encodeURIComponent(options.join(',')) + otherBits;
  289. if (checked('lmo-allow-all-passes')) {
  290. url += '&subscription=*';
  291. }
  292. const vehicleType = _settings['lmo-vehicle-type'].value;
  293. if (vehicleType !== 'private') {
  294. url += `&vehicleType=${vehicleType.toUpperCase()}`;
  295. }
  296. args[0] = url;
  297. }
  298. return origFetch.apply(this, args);
  299. };
  300. })(window.fetch);
  301. }
  302.  
  303. function init() {
  304. // Insert CSS styling.
  305. $('head').append($('<style>', { type: 'text/css' }).html(CSS));
  306.  
  307. // Add the xmlhttp request interceptor, so we can insert our own options into the routing requests.
  308. installHttpRequestInterceptor();
  309.  
  310. // Add all of the DOM stuff for this script.
  311. addOptions();
  312.  
  313. // Watch for the "waiting" spinner so we can disable and enable things while LM is fetching routes.
  314. let observer = new MutationObserver(mutations => {
  315. mutations.forEach(mutation => {
  316. if (mutation.attributeName === 'class') {
  317. const waitingSpinner = !$(mutation.target).hasClass('wm-hidden');
  318. $('.lmo-control').prop('disabled', waitingSpinner);
  319. if (!waitingSpinner) {
  320. W.app.map.fitBounds = _fitBounds;
  321. }
  322. }
  323. });
  324. });
  325. observer.observe($('.wm-route-search__spinner')[0], { childList: false, subtree: false, attributes: true });
  326.  
  327.  
  328. // Watch for routes being displayed, so we can update the displayed times.
  329. observer = new MutationObserver(mutations => {
  330. mutations.forEach(mutation => {
  331. for (let i = 0; i < mutation.addedNodes.length; i++) {
  332. const addedNode = mutation.addedNodes[i];
  333. if (addedNode.nodeType === Node.ELEMENT_NODE && $(addedNode).hasClass('wm-route-list__routes')) {
  334. updateTimes();
  335. }
  336. }
  337. });
  338. });
  339. observer.observe($('.wm-route-list')[0], { childList: true, subtree: true });
  340.  
  341. // Remove the div that contains the native LiveMap options for setting departure time.
  342. $('div.wm-route-schedule').remove();
  343.  
  344. // Remove the routing tip (save some space).
  345. $('div.wm-routing__tip').remove();
  346.  
  347. // Store the fitBounds function. It is removed and re-added, to prevent the
  348. // LiveMap api from moving the map to the boundaries of the routes every time
  349. // an option is checked.
  350. _fitBounds = W.app.map.fitBounds;
  351. }
  352.  
  353. // Run the script.
  354. function bootstrap(tries = 1) {
  355. if ($ && $('.wm-route-search__spinner').length) {
  356. init();
  357. } else if (tries < 1000) {
  358. setTimeout(() => { bootstrap(tries + 1); }, 200);
  359. }
  360. }
  361. bootstrap();