]> git.openstreetmap.org Git - nominatim-ui.git/blob - dist/assets/js/nominatim-ui.js
e36e866610a056b0bf8c07fe52e4371c1422e61a
[nominatim-ui.git] / dist / assets / js / nominatim-ui.js
1 'use strict';
2
3 var map;
4 var last_click_latlng;
5
6 // *********************************************************
7 // DEFAULTS
8 // *********************************************************
9
10 var Nominatim_Config_Defaults = {
11   Nominatim_API_Endpoint: 'http://localhost/nominatim/',
12   Images_Base_Url: '/mapicons/',
13   Search_AreaPolygons: 1,
14   Reverse_Default_Search_Zoom: 18,
15   Map_Default_Lat: 20.0,
16   Map_Default_Lon: 0.0,
17   Map_Default_Zoom: 2,
18   Map_Tile_URL: 'https://{s}.tile.osm.org/{z}/{x}/{y}.png',
19   Map_Tile_Attribution: '<a href="https://osm.org/copyright">OpenStreetMap contributors</a>'
20 };
21
22 // *********************************************************
23 // HELPERS
24 // *********************************************************
25
26
27 function get_config_value(str, default_val) {
28   var value = ((typeof Nominatim_Config !== 'undefined')
29                && (typeof Nominatim_Config[str] !== 'undefined'))
30     ? Nominatim_Config[str]
31     : Nominatim_Config_Defaults[str];
32   return (typeof value !== 'undefined' ? value : default_val);
33 }
34
35 function parse_and_normalize_geojson_string(part) {
36   // normalize places the geometry into a featurecollection, similar to
37   // https://github.com/mapbox/geojson-normalize
38   var parsed_geojson = {
39     type: 'FeatureCollection',
40     features: [
41       {
42         type: 'Feature',
43         geometry: part,
44         properties: {}
45       }
46     ]
47   };
48   return parsed_geojson;
49 }
50
51 function map_link_to_osm() {
52   var zoom = map.getZoom();
53   var lat = map.getCenter().lat;
54   var lng = map.getCenter().lng;
55   return 'https://openstreetmap.org/#map=' + zoom + '/' + lat + '/' + lng;
56 }
57
58 function map_viewbox_as_string() {
59   var bounds = map.getBounds();
60   var west = bounds.getWest();
61   var east = bounds.getEast();
62
63   if ((east - west) >= 360) { // covers more than whole planet
64     west = map.getCenter().lng - 179.999;
65     east = map.getCenter().lng + 179.999;
66   }
67   east = L.latLng(77, east).wrap().lng;
68   west = L.latLng(77, west).wrap().lng;
69
70   return [
71     west.toFixed(5), // left
72     bounds.getNorth().toFixed(5), // top
73     east.toFixed(5), // right
74     bounds.getSouth().toFixed(5) // bottom
75   ].join(',');
76 }
77
78
79 // *********************************************************
80 // PAGE HELPERS
81 // *********************************************************
82
83 function generate_full_api_url(endpoint_name, params) {
84   //
85   // `&a=&b=&c=1` => '&c=1'
86   var param_names = Object.keys(params);
87   for (var i = 0; i < param_names.length; i += 1) {
88     var val = params[param_names[i]];
89     if (typeof (val) === 'undefined' || val === '' || val === null) {
90       delete params[param_names[i]];
91     }
92   }
93
94   var api_url = get_config_value('Nominatim_API_Endpoint') + endpoint_name + '.php?'
95                   + $.param(params);
96   return api_url;
97 }
98
99 function fetch_from_api(endpoint_name, params, callback) {
100   var api_url = generate_full_api_url(endpoint_name, params);
101   if (endpoint_name !== 'status') {
102     $('#api-request-link').attr('href', api_url);
103   }
104   $.get(api_url, function (data) {
105     callback(data);
106   });
107 }
108
109 function update_data_date() {
110   fetch_from_api('status', { format: 'json' }, function (data) {
111     $('#data-date').text(data.data_updated);
112   });
113 }
114
115 function render_template(el, template_name, page_context) {
116   var template_source = $('#' + template_name).text();
117   var template = Handlebars.compile(template_source);
118   var html = template(page_context);
119   el.html(html);
120 }
121
122 function update_html_title(title) {
123   var prefix = '';
124   if (title && title.length > 1) {
125     prefix = title + ' | ';
126   }
127   $('head title').text(prefix + 'OpenStreetMap Nominatim');
128 }
129
130 function show_error(html) {
131   $('#error-overlay').html(html).show();
132 }
133
134 function hide_error() {
135   $('#error-overlay').empty().hide();
136 }
137
138
139 jQuery(document).ready(function () {
140   hide_error();
141
142   $(document).ajaxStart(function () {
143     $('#loading').fadeIn('fast');
144   }).ajaxComplete(function () {
145     $('#loading').fadeOut('fast');
146   }).ajaxError(function (event, jqXHR, ajaxSettings/* , thrownError */) {
147     // console.log(thrownError);
148     // console.log(ajaxSettings);
149     var url = ajaxSettings.url;
150     show_error('Error fetching results from <a href="' + url + '">' + url + '</a>');
151   });
152 });
153 // *********************************************************
154 // DETAILS PAGE
155 // *********************************************************
156
157
158 function init_map_on_detail_page(lat, lon, geojson) {
159   var attribution = get_config_value('Map_Tile_Attribution') || null;
160   map = new L.map('map', {
161     // center: [nominatim_map_init.lat, nominatim_map_init.lon],
162     // zoom:   nominatim_map_init.zoom,
163     attributionControl: (attribution && attribution.length),
164     scrollWheelZoom: true, // !L.Browser.touch,
165     touchZoom: false
166   });
167
168   L.tileLayer(get_config_value('Map_Tile_URL'), {
169     // moved to footer
170     // '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
171     attribution: attribution
172   }).addTo(map);
173
174   // var layerGroup = new L.layerGroup().addTo(map);
175
176   var circle = L.circleMarker([lat, lon], {
177     radius: 10, weight: 2, fillColor: '#ff7800', color: 'blue', opacity: 0.75
178   });
179   map.addLayer(circle);
180
181   if (geojson) {
182     var geojson_layer = L.geoJson(
183       // https://leafletjs.com/reference-1.0.3.html#path-option
184       parse_and_normalize_geojson_string(geojson),
185       {
186         style: function () {
187           return { interactive: false, color: 'blue' };
188         }
189       }
190     );
191     map.addLayer(geojson_layer);
192     map.fitBounds(geojson_layer.getBounds());
193   } else {
194     map.setView([lat, lon], 10);
195   }
196
197   var osm2 = new L.TileLayer(
198     get_config_value('Map_Tile_URL'),
199     {
200       minZoom: 0,
201       maxZoom: 13,
202       attribution: (get_config_value('Map_Tile_Attribution') || null)
203     }
204   );
205   (new L.Control.MiniMap(osm2, { toggleDisplay: true })).addTo(map);
206 }
207
208
209 function details_page_load() {
210
211   var search_params = new URLSearchParams(window.location.search);
212   // var place_id = search_params.get('place_id');
213
214   var api_request_params = {
215     place_id: search_params.get('place_id'),
216     osmtype: search_params.get('osmtype'),
217     osmid: search_params.get('osmid'),
218     keywords: search_params.get('keywords'),
219     addressdetails: 1,
220     hierarchy: (search_params.get('hierarchy') === '1' ? 1 : 0),
221     group_hierarchy: 1,
222     polygon_geojson: 1,
223     format: 'json'
224   };
225
226   if (api_request_params.place_id || (api_request_params.osmtype && api_request_params.osmid)) {
227     fetch_from_api('details', api_request_params, function (aFeature) {
228       var context = { aPlace: aFeature, base_url: window.location.search };
229
230       render_template($('main'), 'detailspage-template', context);
231       if (api_request_params.place_id) {
232         update_html_title('Details for ' + api_request_params.place_id);
233       } else {
234         update_html_title('Details for ' + api_request_params.osmtype + api_request_params.osmid);
235       }
236
237       update_data_date();
238
239       var lat = aFeature.centroid.coordinates[1];
240       var lon = aFeature.centroid.coordinates[0];
241       init_map_on_detail_page(lat, lon, aFeature.geometry);
242     });
243   } else {
244     render_template($('main'), 'detailspage-index-template');
245   }
246
247   $('#form-by-type-and-id,#form-by-osm-url').on('submit', function (e) {
248     e.preventDefault();
249
250     var val = $(this).find('input[type=edit]').val();
251     var matches = val.match(/^\s*([NWR])(\d+)\s*$/i);
252
253     if (!matches) {
254       matches = val.match(/\/(relation|way|node)\/(\d+)\s*$/);
255     }
256
257     if (matches) {
258       $(this).find('input[name=osmtype]').val(matches[1].charAt(0).toUpperCase());
259       $(this).find('input[name=osmid]').val(matches[2]);
260       $(this).get(0).submit();
261     } else {
262       alert('invalid input');
263     }
264   });
265 }
266
267 // *********************************************************
268 // FORWARD/REVERSE SEARCH PAGE
269 // *********************************************************
270
271
272 function display_map_position(mouse_lat_lng) {
273   //
274   if (mouse_lat_lng) {
275     mouse_lat_lng = map.wrapLatLng(mouse_lat_lng);
276   }
277
278   var html_mouse = 'mouse position: -';
279   if (mouse_lat_lng) {
280     html_mouse = 'mouse position: '
281                   + [mouse_lat_lng.lat.toFixed(5), mouse_lat_lng.lng.toFixed(5)].join(',');
282   }
283   var html_click = 'last click: -';
284   if (last_click_latlng) {
285     html_click = 'last click: '
286                   + [last_click_latlng.lat.toFixed(5), last_click_latlng.lng.toFixed(5)].join(',');
287   }
288
289   var html_center = 'map center: '
290     + map.getCenter().lat.toFixed(5) + ',' + map.getCenter().lng.toFixed(5)
291     + ' <a target="_blank" href="' + map_link_to_osm() + '">view on osm.org</a>';
292
293   var html_zoom = 'map zoom: ' + map.getZoom();
294   var html_viewbox = 'viewbox: ' + map_viewbox_as_string();
295
296   $('#map-position-inner').html([
297     html_center,
298     html_zoom,
299     html_viewbox,
300     html_click,
301     html_mouse
302   ].join('<br/>'));
303
304   var center_lat_lng = map.wrapLatLng(map.getCenter());
305   var reverse_params = {
306     lat: center_lat_lng.lat.toFixed(5),
307     lon: center_lat_lng.lng.toFixed(5)
308     // zoom: 2,
309     // format: 'html'
310   };
311   $('#switch-to-reverse').attr('href', 'reverse.html?' + $.param(reverse_params));
312
313   $('input#use_viewbox').trigger('change');
314 }
315
316 function init_map_on_search_page(is_reverse_search, nominatim_results, request_lat,
317   request_lon, init_zoom) {
318
319   var attribution = get_config_value('Map_Tile_Attribution') || null;
320   map = new L.map('map', {
321     // center: [nominatim_map_init.lat, nominatim_map_init.lon],
322     // zoom:   nominatim_map_init.zoom,
323     attributionControl: (attribution && attribution.length),
324     scrollWheelZoom: true, // !L.Browser.touch,
325     touchZoom: false
326   });
327
328
329   L.tileLayer(get_config_value('Map_Tile_URL'), {
330     // moved to footer
331     // '&copy; <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
332     attribution: attribution
333   }).addTo(map);
334
335   // console.log(Nominatim_Config);
336
337   map.setView([request_lat, request_lon], init_zoom);
338
339   var osm2 = new L.TileLayer(get_config_value('Map_Tile_URL'), {
340     minZoom: 0,
341     maxZoom: 13,
342     attribution: attribution
343   });
344   new L.Control.MiniMap(osm2, { toggleDisplay: true }).addTo(map);
345
346   if (is_reverse_search) {
347     // We don't need a marker, but an L.circle instance changes radius once you zoom in/out
348     var cm = L.circleMarker(
349       [request_lat, request_lon],
350       {
351         radius: 5,
352         weight: 2,
353         fillColor: '#ff7800',
354         color: 'red',
355         opacity: 0.75,
356         zIndexOffset: 100,
357         clickable: false
358       }
359     );
360     cm.addTo(map);
361   } else {
362     var search_params = new URLSearchParams(window.location.search);
363     var viewbox = search_params.get('viewbox');
364     if (viewbox) {
365       var coords = viewbox.split(','); // <x1>,<y1>,<x2>,<y2>
366       var bounds = L.latLngBounds([coords[1], coords[0]], [coords[3], coords[2]]);
367       L.rectangle(bounds, {
368         color: '#69d53e',
369         weight: 3,
370         dashArray: '5 5',
371         opacity: 0.8,
372         fill: false
373       }).addTo(map);
374     }
375   }
376
377   var MapPositionControl = L.Control.extend({
378     options: {
379       position: 'topright'
380     },
381     onAdd: function (/* map */) {
382       var container = L.DomUtil.create('div', 'my-custom-control');
383
384       $(container).text('show map bounds')
385         .addClass('leaflet-bar btn btn-sm btn-outline-secondary')
386         .on('click', function (e) {
387           e.preventDefault();
388           e.stopPropagation();
389           $('#map-position').show();
390           $(container).hide();
391         });
392       $('#map-position-close a').on('click', function (e) {
393         e.preventDefault();
394         e.stopPropagation();
395         $('#map-position').hide();
396         $(container).show();
397       });
398
399       return container;
400     }
401   });
402
403   map.addControl(new MapPositionControl());
404
405
406
407
408
409   function update_viewbox_field() {
410     // hidden HTML field
411     $('input[name=viewbox]')
412       .val($('input#use_viewbox')
413         .prop('checked') ? map_viewbox_as_string() : '');
414   }
415
416   map.on('move', function () {
417     display_map_position();
418     update_viewbox_field();
419   });
420
421   map.on('mousemove', function (e) {
422     display_map_position(e.latlng);
423   });
424
425   map.on('click', function (e) {
426     last_click_latlng = e.latlng;
427     display_map_position();
428   });
429
430   map.on('load', function () {
431     display_map_position();
432   });
433
434   $('input#use_viewbox').on('change', function () {
435     update_viewbox_field();
436   });
437
438   function get_result_element(position) {
439     return $('.result').eq(position);
440   }
441   // function marker_for_result(result) {
442   //   return L.marker([result.lat, result.lon], { riseOnHover: true, title: result.name });
443   // }
444   function circle_for_result(result) {
445     var cm_style = {
446       radius: 10,
447       weight: 2,
448       fillColor: '#ff7800',
449       color: 'blue',
450       opacity: 0.75,
451       clickable: !is_reverse_search
452     };
453     return L.circleMarker([result.lat, result.lon], cm_style);
454   }
455
456   var layerGroup = (new L.layerGroup()).addTo(map);
457
458   function highlight_result(position, bool_focus) {
459     var result = nominatim_results[position];
460     if (!result) { return; }
461     var result_el = get_result_element(position);
462
463     $('.result').removeClass('highlight');
464     result_el.addClass('highlight');
465
466     layerGroup.clearLayers();
467
468     if (result.lat) {
469       var circle = circle_for_result(result);
470       circle.on('click', function () {
471         highlight_result(position);
472       });
473       layerGroup.addLayer(circle);
474     }
475
476     if (result.boundingbox) {
477       var bbox = [
478         [result.boundingbox[0] * 1, result.boundingbox[2] * 1],
479         [result.boundingbox[1] * 1, result.boundingbox[3] * 1]
480       ];
481       map.fitBounds(bbox);
482
483       if (result.geojson && result.geojson.type.match(/(Polygon)|(Line)/)) {
484         //
485         var geojson_layer = L.geoJson(
486           parse_and_normalize_geojson_string(result.geojson),
487           {
488             // https://leafletjs.com/reference-1.0.3.html#path-option
489             style: function (/* feature */) {
490               return { interactive: false, color: 'blue' };
491             }
492           }
493         );
494         layerGroup.addLayer(geojson_layer);
495       }
496       // else {
497       //     var layer = L.rectangle(bounds, {color: "#ff7800", weight: 1} );
498       //     layerGroup.addLayer(layer);
499       // }
500     } else {
501       var result_coord = L.latLng(result.lat, result.lon);
502       if (result_coord) {
503         if (is_reverse_search) {
504           // console.dir([result_coord, [request_lat, request_lon]]);
505           // make sure the search coordinates are in the map view as well
506           map.fitBounds(
507             [result_coord, [request_lat, request_lon]],
508             {
509               padding: [50, 50],
510               maxZoom: map.getZoom()
511             }
512           );
513         } else {
514           map.panTo(result_coord, result.zoom || get_config_value('Map_Default_Zoom'));
515         }
516       }
517     }
518     if (bool_focus) {
519       $('#map').focus();
520     }
521   }
522
523
524   $('.result').on('click', function () {
525     highlight_result($(this).data('position'), true);
526   });
527
528   if (is_reverse_search) {
529     map.on('click', function (e) {
530       $('form input[name=lat]').val(e.latlng.lat);
531       $('form input[name=lon]').val(e.latlng.wrap().lng);
532       $('form').submit();
533     });
534
535     $('#switch-coords').on('click', function (e) {
536       e.preventDefault();
537       e.stopPropagation();
538       var lat = $('form input[name=lat]').val();
539       var lon = $('form input[name=lon]').val();
540       $('form input[name=lat]').val(lon);
541       $('form input[name=lon]').val(lat);
542       $('form').submit();
543     });
544   }
545
546   highlight_result(0, false);
547
548   // common mistake is to copy&paste latitude and longitude into the 'lat' search box
549   $('form input[name=lat]').on('change', function () {
550     var coords_split = $(this).val().split(',');
551     if (coords_split.length === 2) {
552       $(this).val(L.Util.trim(coords_split[0]));
553       $(this).siblings('input[name=lon]').val(L.Util.trim(coords_split[1]));
554     }
555   });
556 }
557
558
559
560 function search_page_load() {
561
562   var is_reverse_search = window.location.pathname.match(/reverse/);
563
564   var search_params = new URLSearchParams(window.location.search);
565
566   // return view('search', [
567   //     'sQuery' => $sQuery,
568   //     'bAsText' => '',
569   //     'sViewBox' => '',
570   //     'aSearchResults' => $aSearchResults,
571   //     'sMoreURL' => 'example.com',
572   //     'sDataDate' => $this->fetch_status_date(),
573   //     'sApiURL' => $url
574   // ]);
575
576   var api_request_params;
577   var context;
578
579   if (is_reverse_search) {
580     api_request_params = {
581       lat: search_params.get('lat'),
582       lon: search_params.get('lon'),
583       zoom: (search_params.get('zoom') > 1
584         ? search_params.get('zoom')
585         : get_config_value('Reverse_Default_Search_Zoom')),
586       format: 'jsonv2'
587     };
588
589     if (search_params.get('debug') === '1') {
590       window.location.href = generate_full_api_url('reverse', api_request_params);
591       return;
592     }
593
594     context = {
595       // aPlace: aPlace,
596       fLat: api_request_params.lat,
597       fLon: api_request_params.lon,
598       iZoom: (search_params.get('zoom') > 1
599         ? api_request_params.zoom
600         : get_config_value('Reverse_Default_Search_Zoom'))
601     };
602
603     update_html_title();
604     if (api_request_params.lat && api_request_params.lon) {
605
606       fetch_from_api('reverse', api_request_params, function (aPlace) {
607
608         if (aPlace.error) {
609           aPlace = null;
610         }
611
612         context.bSearchRan = true;
613         context.aPlace = aPlace;
614
615         render_template($('main'), 'reversepage-template', context);
616         update_html_title('Reverse result for '
617                             + api_request_params.lat
618                             + ','
619                             + api_request_params.lon);
620
621         init_map_on_search_page(
622           is_reverse_search,
623           [aPlace],
624           api_request_params.lat,
625           api_request_params.lon,
626           api_request_params.zoom
627         );
628
629         update_data_date();
630       });
631     } else {
632       render_template($('main'), 'reversepage-template', context);
633
634       init_map_on_search_page(
635         is_reverse_search,
636         [],
637         get_config_value('Map_Default_Lat'),
638         get_config_value('Map_Default_Lon'),
639         get_config_value('Map_Default_Zoom')
640       );
641     }
642
643   } else {
644     api_request_params = {
645       q: search_params.get('q'),
646       street: search_params.get('street'),
647       city: search_params.get('city'),
648       county: search_params.get('county'),
649       state: search_params.get('state'),
650       country: search_params.get('country'),
651       postalcode: search_params.get('postalcode'),
652       polygon_geojson: get_config_value('Search_AreaPolygons', false) ? 1 : 0,
653       viewbox: search_params.get('viewbox'),
654       exclude_place_ids: search_params.get('exclude_place_ids'),
655       format: 'jsonv2'
656     };
657
658     if (search_params.get('debug') === '1') {
659       window.location.href = generate_full_api_url('search', api_request_params);
660       return;
661     }
662
663     context = {
664       sQuery: api_request_params.q,
665       sViewBox: search_params.get('viewbox'),
666       env: {}
667     };
668
669     if (api_request_params.street || api_request_params.city || api_request_params.county
670       || api_request_params.state || api_request_params.country || api_request_params.postalcode) {
671       context.hStructured = {
672         street: api_request_params.street,
673         city: api_request_params.city,
674         county: api_request_params.county,
675         state: api_request_params.state,
676         country: api_request_params.country,
677         postalcode: api_request_params.postalcode
678       };
679     }
680
681     if (api_request_params.q || context.hStructured) {
682
683       fetch_from_api('search', api_request_params, function (aResults) {
684
685         context.bSearchRan = true;
686         context.aSearchResults = aResults;
687
688         // lonvia wrote: https://github.com/osm-search/nominatim-ui/issues/24
689         // I would suggest to remove the guessing and always show the link. Nominatim only returns
690         // one or two results when it believes the result to be a good enough match.
691         // if (aResults.length >= 10) {
692         var aExcludePlaceIds = [];
693         if (search_params.has('exclude_place_ids')) {
694           aExcludePlaceIds = search_params.get('exclude_place_ids').split(',');
695         }
696         for (var i = 0; i < aResults.length; i += 1) {
697           aExcludePlaceIds.push(aResults[i].place_id);
698         }
699         var parsed_url = new URLSearchParams(window.location.search);
700         parsed_url.set('exclude_place_ids', aExcludePlaceIds.join(','));
701         context.sMoreURL = '?' + parsed_url.toString();
702
703         render_template($('main'), 'searchpage-template', context);
704         update_html_title('Result for ' + api_request_params.q);
705
706         init_map_on_search_page(
707           is_reverse_search,
708           aResults,
709           get_config_value('Map_Default_Lat'),
710           get_config_value('Map_Default_Lon'),
711           get_config_value('Map_Default_Zoom')
712         );
713
714         $('#q').focus();
715
716         update_data_date();
717       });
718     } else {
719       render_template($('main'), 'searchpage-template', context);
720
721       init_map_on_search_page(
722         is_reverse_search,
723         [],
724         get_config_value('Map_Default_Lat'),
725         get_config_value('Map_Default_Lon'),
726         get_config_value('Map_Default_Zoom')
727       );
728     }
729   }
730 }
731
732
733 // *********************************************************
734 // DELETABLE PAGE
735 // *********************************************************
736
737 function deletable_page_load() {
738
739   var api_request_params = {
740     format: 'json'
741   };
742
743   fetch_from_api('deletable', api_request_params, function (aPolygons) {
744     var context = { aPolygons: aPolygons };
745
746     render_template($('main'), 'deletable-template', context);
747     update_html_title('Deletable objects');
748
749     update_data_date();
750   });
751 }
752 // *********************************************************
753 // BROKEN POLYGON PAGE
754 // *********************************************************
755
756 function polygons_page_load() {
757   //
758   var api_request_params = {
759     format: 'json'
760   };
761
762   fetch_from_api('polygons', api_request_params, function (aPolygons) {
763     var context = { aPolygons: aPolygons };
764
765     render_template($('main'), 'polygons-template', context);
766     update_html_title('Broken polygons');
767
768     update_data_date();
769   });
770 }
771 jQuery(document).ready(function () {
772   var myhistory = [];
773
774   function parse_url_and_load_page() {
775     // 'search', 'reverse', 'details'
776     var pagename = window.location.pathname.replace('.html', '').replace(/^.*\//, '');
777
778     if (pagename === '') pagename = 'search';
779
780     $('body').attr('id', pagename + '-page');
781
782     if (pagename === 'search' || pagename === 'reverse') {
783       search_page_load();
784     } else if (pagename === 'details') {
785       details_page_load();
786     } else if (pagename === 'deletable') {
787       deletable_page_load();
788     } else if (pagename === 'polygons') {
789       polygons_page_load();
790     }
791   }
792
793   function is_relative_url(url) {
794     if (!url) return false;
795     if (url.indexOf('?') === 0) return true;
796     if (url.indexOf('/') === 0) return true;
797     if (url.indexOf('#') === 0) return false;
798     if (url.match(/^http/)) return false;
799     if (!url.match(/\.html/)) return true;
800
801     return false;
802   }
803
804   // remove any URL paramters with empty values
805   // '&empty=&filled=value' => 'filled=value'
806   function clean_up_url_parameters(url) {
807     var url_params = new URLSearchParams(url);
808     var to_delete = []; // deleting inside loop would skip iterations
809     url_params.forEach(function (value, key) {
810       if (value === '') to_delete.push(key);
811     });
812     for (var i = 0; i < to_delete.length; i += 1) {
813       url_params.delete(to_delete[i]);
814     }
815     return url_params.toString();
816   }
817
818   parse_url_and_load_page();
819
820   // load page after form submit
821   $(document).on('submit', 'form', function (e) {
822     e.preventDefault();
823
824     var target_url = $(this).serialize();
825     target_url = clean_up_url_parameters(target_url);
826
827     window.history.pushState(myhistory, '', '?' + target_url);
828
829     parse_url_and_load_page();
830   });
831
832   // load page after click on relative URL
833   $(document).on('click', 'a', function (e) {
834     var target_url = $(this).attr('href');
835     if (!is_relative_url(target_url)) return;
836
837     e.preventDefault();
838     e.stopPropagation();
839
840     window.history.pushState(myhistory, '', target_url);
841
842     parse_url_and_load_page();
843   });
844
845   // deal with back-button and other user action
846   window.onpopstate = function () {
847     parse_url_and_load_page();
848   };
849 });
850