Colin Champion

Summary: this page contains technical documentation and javascript source code for the routemaster GPS track editor.

user doc : technical doc : about the software : Garmin 500 review : source : download & index

• general functions     • editing functions     • uploading and downloading     • putting routemaster on your own website     • adding photos to your own instance     • creating an overview    

Routemaster may be used to edit and display GPS tracks and to display multi-track indexes. Photographs may be included. However the web display requires the user to have his or her own instance of the tool (which means in practice a 10-line HTML page calling in functions from my own libraries). Editing and downloading can be performed by anyone.

I would like to think that routemaster was self-explanatory but in case of difficulty there’s a help menu under the cogwheel button. The same menu is displayed under the load prompt.

To load a track into routemaster for editing, go to www.masterlyinactivity.com/routemaster and follow the prompts.

To see the tool in action, look at the Cleeve Hill circuit. By all means experiment with splitting the route, deleting parts of it, etc.: you can’t do any harm. You can download the result to your computer.

To see a route index go to our Cape Verde tracks. Click on any of the routes there for more information and some photos. If you click again to view its track you will see how photos are embedded at specific locations

I hope some of these are found useful. In addition there are two functions which can only be made use of by providing a routemaster instance on your own website, namely using it to display routes on the web and including photos.

There are technical reasons (see below) why the official routemaster on www.masterlyinactivity.com can’t be used to display gps tracks held elsewhere on the web.

To load a track into a Garmin:

The Garmin fails to convert the files if any fields are missing which it considers obligatory, or if it encounters any formatting errors. It’s very fragile. You get no information as to why a track was rejected and there’s no documentation to tell you how to make a track it accepts.

For an example of a route incorporating photos, see the Monte Tondo circuit we rode in Tuscany in June 2015 (the GPS track is actually Daniele Saisi’s).

routemaster is a trivial web page which invokes a javascript library to do all its work. If you invoke the same library from a page on your own site then you will be able to link your own tracks into it.

To do this, create a web page, eg. routemaster.html, with similar content to my own (www.masterlyinactivity.com/routemaster/index.html). This is just a few lines:

<html><head>
  <meta http-equiv="content-type" content="text/html; charset=UTF-8"/>
  <script src="http://www.masterlyinactivity.com/routemaster/resources/routemaster.js"></script>
  <script>genhead("http://www.masterlyinactivity.com/routemaster/resources",
'AIzaSyD2oAnc1Acy1tv2ov55fLUPKZl_sKoS4rQ') </script>
</head><body></body>
<script>genpage()</script>
</html>

The second parameter in the call to genhead is a Google Maps API key.

Once you have a routemaster.html page, if you visit it you should be given the upload prompt. Alternatively visit it with a parametric track name:

www.mysite.org.uk/routemaster.html/?track=rides/longride.tcx

where the part before the ‘?track=’ is the URL of your routemaster instance and the part after is the path, relative to that instance, to a TCX (or GPX) track.

You can now link to this composite URL instead of typing it into the URL bar.

None of this is within the scope of naive users; and knowledgeable ones should be aware that anything they do may cease to work if Google start charging for access to the maps API (see below). If you’re at all tempted to experiment with your own instance of routemaster, I would advise you to hold back until you’ve made yourself comfortable with pix.js.

The code for photo handling is not as user-friendly as the rest of routemaster. It was written for my own use.

Firstly you need the photos and a list in the format defined for my pix.js script. This is the hard part. Put the list in its intended location on your website.

Now load a track, passing the list location to routemaster through an extra component in the URL. If you are going to view a track uploaded from your own computer, write the following in the URL bar:

www.mysite.org.uk/routemaster.html/?list=../ruritania/list.js

If you are viewing a track already on the web, write

www.mysite.org.uk/routemaster.html/?track=rides/longride.tcx&list=../ruritania/list.js

Here the part following the ‘list=’ is the path to the photo list from your routemaster instance.

If you are uploading a track from your computer, do so now.

You should now see the track in the normal way. But if at some point you right click on the pen button, you will be prompted for the name of a photo, and if you choose a name from the list you should see its thumbnail in response. From this point on you can edit photos in much the same way as you can edit course points.

Now download the track. It will have extra entries for the photos you’ve added and also (near the end) the URL of the photo list you specified. If you use this version of the track instead of the original, you will not need to append the photo list path to the routemaster URL. You can link to the track in the same way as before and the photos will be embedded within it.

I wish I could say that this was plain sailing but I often have difficulties myself.

A problem which sometimes arises is that something in the Internet occasionally truncates www.masterlyinactivity.com to just masterlyinactivity.com, and something else then complains that an attempt to access one of these from the other violates the same origin policy.

In case of difficulty, in Firefox, open the web console (Tools > Web Developer > Web console): you may see some informative messages. Presumably other browsers offer a similar feature. Don’t use IE.

To create an index, load the first route into routemaster. Then load subsequent routes as new segments. Set a title for the index. At this stage the segments will alternate red and pink and be connected by dashed lines as if they were parts of a single route.

Click on ‘Download track as route index’ and the index file will appear on your computer. If you upload it to your own website and have a routemaster instance to serve it, you can supply a path to it after the ‘?’ in the routemaster URL to see it as it is meant to appear. You will probably have to do a little hand-editing of the downloaded file if you want links to work.

• data acquisition     • data transfer     • optimisation     • actions list     • mapping     • photos     • altitudes     • times     • downloading     • acknowledgments    

routemaster uses the relatively new HTML5 FileReader API to upload GPS tracks from the user’s disc. This is fine for editing.

The other function I aimed for with Routemaster is as a linkable display of GPS tracks. To achieve this I need to supply the track uri to Routemaster, which I achieve by appending it to the Routemaster uri after a ‘?’, eg.

www.masterlyinactivity.com/routemaster/?track=routes/montetondo.tcx

The track is then read in using the (also fairly new) ‘XMLHttpRequest’ function. Unfortunately this is subject to the internet same origin policy which makes it impossible for routemaster to display a track which isn’t hosted on www.masterlyinactivity.com. Since I have no plans to offer a track hosting service this limits the use which anyone else can make of the official instance of routemaster.

The only workaround I can see for this is to specify a Javascript format for GPS tracks. I could easily download tracks as Javascript rather than as tcx, and I think I could then get hold of a track from its uri even across domains. The idea of embedding data in executable code to circumvent access restrictions definitely has mileage.

Routemaster is a client-side web application which reads data into the browser, processes it there, and re-exports it. Nothing is sent up to masterlyinactivity.com. There are no cookies. But Google knows where you’ve been because of the mapping requests.

A track is optimised on input; that is, it is reduced to the smallest number of trackpoints to preserve its accuracy. This is done using dynamic programming. Bike route toaster uses the Ramer–Douglas–Peucker algorithm which I think is less effective; I suspect some programs simply take every nth waypoint.

Optimisation is performed on input for several reasons: so that the editing is truly WYSIWYG; because handling lots of redundant waypoints is cumbersome; and to give the user the opportunity to adjust the optimisation parameters.

Optimisation, although automatically performed, is treated as a user edit which can be undone and redone. If – immediately after loading a track – you hit ‘undo’ you go back to the unoptimised track. You will them be given an ‘Optimise’ option from the main menu (the cogwheel) and can specify your own parameters. But you are unlikely to want to do this.

To be precise the dynamic program reduces the set of waypoints to the optimum subject to two constraints. The first constraint is that the new route lies within distance tol of the one implied by the original set (interpolating linearly); the second is that the separation between two successive points is never greater than maxsep; tol and maxsep default to 10m and 95m respectively.

Optimality means having least cost where the cost is the sum of two terms: the volume of the error sleeve and a penalty for each waypoint.

The error sleeve is a rough cylinder which follows the new route and has a radius equal to the distance between new and old routes. Its volume is a figure in cubic metres.

The penalty is added once for every waypoint included. The default is 1000m3. This is quite small: it equates to an error of about 4m over a distance of 20m.

Vertical errors are given the same weight as horizontal errors, but although this is natural it has no real justification. An additional parameter (vweight) can be used to adjust the significance of altidude. It would make sense to be particularly averse to losing peaks and troughs since this affects the calculation of total ascent; but I haven’t done anything to this effect.

Given that the cost function does a good job of adjudicating between sets of waypoints it’s tempting to dispense with the tolerance altogether, or at least to make it very lax. But this might lead to a surfeit of ‘Off course’ messages. It would also make the algorithm more expensive since the tolerance (together with the maximum separation) allows hard cutoffs to be applied. The maximum separation is needed because Garmin 500s lose their breadcrumb display if a separation is >100m. (See the Garmin support forum). But I print positions with precision around 1m, so I set maxsep somewhat less than 100m to avoid it becoming greater due to rounding error.

Once a track has been optimised I try to avoid optimising it a second time, especially since – even if the parameters are the same – the results will differ: there will be an accumulation of errors. For this reason I record the fact that optimisation has been performed in the exported track. Also, if optimisation reduces the number of points by less than a factor of 2, I assume that the track has been preoptimised.

A side-effect of optimisation which took me by surprise if that if while riding along you lose confidence in the route you are following, backtrack a few tens of metres, and then realise you were right all along and resume your previous course, then the optimisation removes all record of your changes of mind.

Don’t conclude from this detailed discussion that optimisation is a critical issue. Different algorithms and different parameters will give similar performance.

In order to provide non-destructive editing I maintain an ‘actions list’ which is a specification of every editing operation which is performed. Undoing an action backsteps through this list; redoing it steps forward; performing a new action puts it at the current position and discards anything which might come after.

It isn’t always obvious what constitutes an editing action (for instance editors never treat changing a selection as an undoable action). To avoid confusion I don’t provide a blind ‘undo’ function but always prompt the user with the action he or she will undo. This provides useful reassurance and also makes it possible to put actions in the list (not only optimisation but also file load) which the user may not be conscious of having performed.

Creating a coursepoint label may require a sequence of user operations which are collapsed into a single action.

I use Google Maps v3 API. I don’t expect to extend this to other map sources.

The buttons at the bottom use the Google map.controls feature. Mostly the buttons don’t do any more than bring up menus which hang off them. These menus are google.maps.InfoWindows whose position needs to be specified as lat/long rather than relative to the controls. The calculation which converts one to the other makes me dependent on unspecified details of the map controls interface and leads to unexpected behaviour when windows are resized.

At the time I didn’t know enough HTML to generate the buttons myself. Now I think I do, but it would still be more work than taking advantage of the Google functions.

I allow photos to be included at their correct points along a route. There’s a certain amount of code to support this feature. I take advantage of the fact that I have a standard Javascript format for photo lists (eg. Garfagnana list) which is used to generate photo pages (eg. Garfagnana photos); the photo pages are generated automatically from the lists by Javascript code.

The same list is used by Routemaster. The user simply associates a photo name with a trackpoint; the list then determines the shapes of the thumbnail and of all full-size images, the title, and any caption.

To insert a photo at the current waypoint, right click on the pen button.

A difficulty I’ve encountered is that of getting the photolist into Routemaster. This may be done in either of two ways. To get started you can supply the photolist after a ‘?list=’ in the uri line, eg.

www.masterlyinactivity.com/routemaster?list=../garfagnana/list.js

(The uri is relative to routemaster, and routemaster, technically, is www.masterlyinactivity.com/routemaster/index.html.)

Then, once photos from a list have been incorporated in a track, if you save the track the uri of the photolist will be embedded in it under an ‘<Extensions>’ tag. The uri specification is no longer needed.

The photos themselves are incorporated in the track simply as their names. The image uris are generated from the names and the list uri.

Points may not have altitudes either because a route was input from a defective GPS track or because waypoints have been added or moved. The handling of these cases has been completely revised in July ’16.

Prior to this date the absence of an altitude from an input track was treated as a fatal error. When points were added or moved a request was immediately sent to the Google elevation service.

One of the changes is to permit the input of GPS tracks with missing altitudes, treating the points in question in exactly the same way as newly added points. The other is to postpone requests to the Google elevation service until there is a large enough batch to justify the transaction. But altitudes need to be filled in before a track is downloaded and the user has the the option (under the cogwheel button) of requesting an update.

The first of the new features is mostly needed to recover from faulty tracks produced by buggy versions of routemaster. The second leads to considerable savings in network traffic, but also – given Google’s pricing model – may one day bring financial savings. In any case the new code is shorter and more reliable than the old.

Rather than directly using the returned altitude of a point, I compute its difference from the nearest waypoint: this ensures consistency if there is a calibration offset. It would be better if I could use the altitudes of all the points which have been optimised away, but even if I’ve retained them I may not know which manual calibration may have been intended to be applied to them. So maybe I shouldn’t worry about this.

The original version of routemaster simply deleted the times (or rather replaced them by nominal times starting on 1 Jan 1970 – Garmins don’t like the field to be absent). This is because if you edit a track substantially (combining segments, reversing some of them, extrapolating points...) it becomes impossible to provide times which correspond to real cycling speeds. Also I’m afraid that if Garmins need the times to be present, they may behave oddly if they decrease.

However the times are useful, especially for collating photos with routes. My current policy is to replace times by nominal values if they are out of sequence, else to preserve them, interpolating and extrapolating if need be. Extrapolation uses the rider’s average speed.

When I started writing Routemaster I had no idea that the W3C had abandoned the FileWriter API. I use Eli Grey’s File Saver instead. This has a known limitation in Safari: the text you ask to download may instead be presented in a separate tab. This sounds like a Safari bug, so with any luck it will be made to go away.

Why do I only download as tcx? There’s a bewildering variety of formats (more than the names suggest: there are at least two versions of each of tcx and gpx). The differences in what they can be used for are minor but the formats differ in respects which have no bearing on their functionality. In the end no one knows what is the right format to use. I have standardised on a variety of tcx (as output by ridewithgps) because I know it does everything I need. It’s good enough for most purposes, but being proprietary doesn’t satisfy everyone.

• to do     • revisions history     • future directions     • testing     • licence    

In 2016 Google removed the ability for new pages to use the Maps API without a key. This makes it somewhat harder for users to set up their own instances of routemaster. I suspect it’s the first step towards monetising the maps API (which at present is effectively supplied as a free public service). Google have reserved the right to put ads all over the maps they supply, and that’s likely to be their first resource. Everyone who doesn’t want ads (including me) will have to adopt a paid service; but if I don’t want ads and want other people to be able to use routemaster without them, I will need to find a way to avoid bearing the fees Google levies on them.

If the Google Maps API starts charging for ad-free use this will impose architectural changes on users. At present authentication uses the HTTP ‘referer’ which is really not fit for any purpose with financial consequences. It will need something more trustworthy, which is likely to lead to users having to provide server authentication, which means that the average citizen with web space will be excluded.

So I will watch what Google do. If it looks like I’m going to have to pay to avoid ads, I may make routemaster a subscription service, in which case I could also host routes and photos. In the meantime I’ll take it easy, but I need to change web hosts in September ’16 and may take the opportunity to introduce a simple registration mechanism.

But I’m back-pedalling on encouraging users to install their own instances, which would probably land them in difficulties within a year of so.

I’ve added a (not very permissive) licence – an unintentional omission from earlier versions.

There are scores of options and features and I can’t test them all every time I make a change; be patient if you encounter a bug (and send me an email).

It’s useful to be able to verify that I can load files from a variety of sources: these links help:

• altarezia.eu GPX    • bikehike GPX route    • bikehike GPX track    • bikehike TCX    • Garmin Connect GPX    • Garmin Connect TCX    • Ride with GPS GPX    • Ride with GPS TCX course    • Ride with GPS TCX history    • Versante Sud free track    • Modica MTB track downloaded from the web   

Also, on every revision, I should make sure that I can transfer a downloaded track to my Garmin and that it will be recognised there (I missed this check out earlier in 2016.)

tcxlib.js (but not routemaster.js or routemasterlib.js) is supplied free of charge under an MIT licence (giving you carte blanche). routemaster.js and routemasterlib.js may be invoked freely from any non-commercial web pages (without warranty of any kind, and under the condition that the author shall not be held liable for any misadventure arising from their use) but no further licence is granted.

[The relatively restrictive terms here reflect a change in how I expect to offer routemaster to others. Originally I saw it as a piece of browser software for my own use which I could happily make available to others (although the limitations stemming from the same-origin policy put it beyond casual users). But the prospect of Google starting to levy charges makes me suspect that it will need to be a web service (that is, one which provides more than web pages and also handles authentication), which is something that cannot be shared in the same way as a piece of software. I don’t want to provide routemaster in a form which can’t be maintained so I make it available to anyone for use but not as something capable of being extended.

tcxlib.js is treated more liberally because it is written (if not documented) in a way which makes it usable by anyone even if access to Google Maps is limited to web services. The same applies to pix.js and pixlib.js.]

When I bought my Garmin I knew what I wanted: I wanted a navigation aid for mountain biking which allowed me to record routes and follow other people’s shared routes, and which could be used in conjunction with paper maps and route descriptions. I did not want inbuilt maps because I didn’t expect them to be adequate. I was particularly interested in mountain biking abroad, including in countries where even paper maps are not of high standard.

After some web browsing I came to the conclusion that the Garmin 500 was closest to what I wanted, and my first impression is that I was exactly right. It has all the functions I need; it has a reasonable price; it is conveniently small; and it has good battery life. But it’s let down by the poor quality of Garmin software.

I wrote an initial review which mentioned some of the faults I’d encountered; here’s a more complete list.

And there are some design faults, often reflecting the fact that Garmin were seeking to provide a training aid rather than a navigation aid.

Finally there are some limitations which the user needs to be aware of but which are due to the nature of the device.

• routemaster.js     • tcxlib.js     • routemasterlib.js    

• function     • genseg     • dotpath     • linepath     • listinfo     • interp     • bearing     • greyout     • blackout     • enterFullscreen     • exitFullscreen     • findimg     • unsavedmsg     • selpoint     • highlight     • getbtnpos     • unambig     • walkto     • keystroke     • shiftkey     • undraw     • redraw     • recolour     • obliterate     • draw     • disconnect     • reconnect     • connect     • redrawconnect     • drawsel     • selclick     • genhead     • genpage     • getlist     • filedialogue     • render     • settitle     • setdesc     • retitle     • restars     • genbutton     • optimaction     • optimprompt     • popup     • calwork     • manualcal     • help     • wpdelwork     • wpdel     • revsegwork     • revseg     • addload     • insert     • inswp     • draggit     • undraggit     • seginfo     • deltimes     • extrapts     • drawprofile     • unprofile     • reprofile     • routeinfo     • combine1     • combinework     • combine     • recombine     • uncombine     • wpinfo     • setalt     • labelprompt     • labelcycle     • photoprompt     • photoedit     • advance     • retreat     • prev     • backtogps     • next     • display     • dodisplay     • imgwalk     • phinfo     • snipwork     • snip     • binwork     • discard     • actiontype     • done     • donesomething     • undo     • confirmedundo     • move     • redo     • confirmedredo     • dl

var segments=[],selected,actions,nactions,sel,dragging,overviewing=0 ; 
var mouseopt=0,resuri,xmlfile=null,shifted=null,unsavedchanges=[] ; 
var body,mapdiv,pro,starsdiv=null,routeprops,map=null,clickhandle=null ; 
var scroller=null,imgdiv,imghandle,imginfo,imgind ;
var cursorbtn,scissorsbtn,binbtn,undobtn,redobtn,penbtn,setbtn=null,dlbtn ;

var defparms = {tol:10,maxsep:95,wppenalty:1000,vweight:1} ;
var parser = new DOMParser() ;

var infowindow = 
{ handle: null , 
  type: null , 
  open: function(s,pos,type)
  { this.handle = new google.maps.InfoWindow({content:s,position:pos}) ;
    this.handle.open(map) ; 
    google.maps.event.addListener(this.handle,'closeclick',function() 
    { if(infowindow.type=='highlight')
      { redraw(selected[0]) ; 
        if(scroller!=null) { clearInterval(scroller) ; scroller = null ; }
        infowindow.handle = null ; 
      }
      else if(infowindow.type=='phinfo') walkto(selected[0],selected[1]) ;
      infowindow.type = starsdiv = null ; 
    } ) ;
    this.type = type ; 
  } , 
  close: function() 
  { if(this.handle==null) return null ; 
    var response = this.type ;
    this.handle.close() ; 
    if(response=='highlight') 
    { redraw(selected[0]) ; 
      if(scroller!=null) { clearInterval(scroller) ; scroller = null ; }
    }
    else if(response=='phinfo') walkto(selected[0],selected[1]) ; 
    this.handle = this.type = starsdiv = null ; 
    return response ; 
  } 
} ; 
/* --------------- construct a segment from an xml document ----------------- */

function genseg(a,b) 
{ this.data = a ; 
  this.props = b ; 
  this.route = this.routehandler = this.dots = this.dothandler = null ; 
  this.colour = "red" ;
}
/* -------------------------------------------------------------------------- */
/*                                CONSTRUCTORS                                */
/* -------------------------------------------------------------------------- */

function dotpath(a,b)
{ this.path = [a,b] ;
  this.cursor = 'default' ;
  this.geodesic = true ;
  this.strokeOpacity = 0 ;
  this.icons = [ { icon:   { path: 'M 0 0 L 1 0',strokeOpacity:1,scale:1 } , 
                   offset: '1px' , 
                   repeat: '4px' 
                  } ] ;
  this.zIndex = 0 ;
}
/* -------------------------------------------------------------------------- */

function linepath(s0,start,end,colour,width)
{ var i,len=(start<0?segments[s0].data.length:end-start) ; 
  if(width==undefined) width = 2 ; 
  this.path = new Array(len) ; 
  if(start<0) for(i=0;i<len;i++) this.path[i] = segments[s0].data[i].pos ;
  else for(i=0;i<len;i++) this.path[i] = segments[s0].data[start+i].pos ; 
  this.clickable = 'false' ;
  this.cursor = 'default' ;
  this.geodesic = true ;
  this.strokeColor = colour ;
  this.strokeOpacity = 1.0 ;
  this.strokeWeight = width ;
  if(width==2) this.zIndex = 0 ; else this.zIndex = 1 ; 
}
/* -------------------------------------------------------------------------- */

function listinfo()
{ this.list = [] ; 
  this.sizes = [] ;
  this.uri = null ; 
  this.thumbind = this.scale = this.status = this.type = this.pixpage = null ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*                             UTILITY FUNCTIONS                              */
/* -------------------------------------------------------------------------- */

function interp(x,y,lamda)
{ return google.maps.geometry.spherical.interpolate(x,y,lamda) ; }

function bearing(x,y)
{ return google.maps.geometry.spherical.computeHeading(x,y) ; }

/* --------------------------- button handlers  ----------------------------- */

function greyout(btn)
{ if(overviewing||btn.active==0) return 0 ; 
  btn.btn.setAttribute('src',btn.greyimg) ; 
  btn.ui.removeEventListener('click',btn.handler) ;
  if(btn==penbtn) btn.ui.removeEventListener('contextmenu',photoprompt) ;
  btn.ui.style.cursor = 'default' ; 
  btn.active = 0 ; 
  return 1 ; 
}
function blackout(btn)
{ if(overviewing||btn.active) return ; 
  btn.btn.setAttribute('src',btn.blackimg) ; 
  btn.ui.addEventListener('click',btn.handler) ;
  if(btn==penbtn) btn.ui.addEventListener('contextmenu',photoprompt) ; 
  btn.ui.style.cursor = 'pointer' ; 
  btn.active = 1 ; 
}
/* ------------------------ enter/exit full screen -------------------------- */

// most of the code is available from pixlib
function enterFullscreen() { infowindow.close() ; enterfullscreen() ; } 

function exitFullscreen() 
{ infowindow.close() ; 
  if(document.exitFullscreen) document.exitFullscreen() ;
  else if(document.mozCancelFullScreen) document.mozCancelFullScreen() ;
  else if(document.webkitExitFullscreen) document.webkitExitFullscreen() ;
}
/* -------------------------------------------------------------------------- */

function findimg(id)
{ var i ; 
  for(i=0;i<imginfo.list.length;i++) 
    if(imginfo.list[i].name!=undefined&&imginfo.list[i].name==id) return i ; 
  return -1 ; 
}
/* ------------------- message warning of unsaved changes ------------------- */

function unsavedmsg(ok)
{ var msg , len = unsavedchanges.length , i ; 
  if(len==0) return null ; 
  msg = 'You have ' + len + ' unsaved change' + (len==1?"":'s') ; 
  if(len<=3) for(i=0;i<len;i++)
     msg += (i?',':' (') + unsavedchanges[i] + (i==len-1?')':'') ;
  msg += '\nIf you hit ' + (ok?'[OK]':'[Leave page]') ; 
  return msg + (len==1?' this change':' these changes') + ' will be lost.' ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------- selpoint: choose the clicked waypoint  ------------------- */

function selpoint(event)
{ var i,j,closest,d,mindist,s0,s1 ; 
  if(dragging) return ; 
  var flag = (infowindow.close()=='wpinfo') && (shifted==0) ; 

  if(overviewing==0&&shifted)
  { s0 = selected[0] ;
    s1 = segments[s0].data.length ;
    insert(s0,s1,1) ;
    segments[s0].data[s1].setpos(event.latLng) ;
    redrawconnect(s0,s1) ;
    done(['move',s0,s1,event.latLng,event.latLng,1]) ; 
  }
  else for(s1=s0=-1,i=0;i<segments.length;i++) 
    for(j=0;j<segments[i].data.length;j++)
  { d = dist(segments[i].data[j].pos,event.latLng) ;
    if(s0<0||d<mindist) { s0 = i ; s1 = j ; mindist = d ; } 
  }
  walkto(s0,s1,flag) ; 
}
/* -------------------------- track highlighter  ---------------------------- */

function highlight()
{ var s0=selected[0],scroll,thind=null ;
  infowindow.close() ;
  undraw(s0) ; 
  draw(s0,4) ;
  if(imginfo.uri!=null) thind = thumbind(imginfo.sizes) ;
  scroll = highdiv(segments[s0].props,imginfo.list,imginfo.sizes,
                   thind,segments[s0].props.photo) ;
  scroller = scroll.scroller ;
  infowindow.open(scroll.div,northernmost(segments[s0].data),'highlight') ; 
}
/* ------------------------------- getbtnpos -------------------------------- */

function getbtnpos(btnno)
{ var bounds=map.getBounds(),sw,ne,lat,lon,lam ;
  sw = bounds.getSouthWest() ; 
  ne = bounds.getNorthEast() ; 
  lam = 52.0 / window.innerHeight ; 
  lat = lam*ne.lat() + (1-lam)*sw.lat() ; 
  lam = 0.5 + (btnno*32-112.0)/window.innerWidth ;
  lon = lam*ne.lng() + (1-lam)*sw.lng() ;
  return new google.maps.LatLng(lat,lon) ; 
}
/* ----- unambig: does the selected waypoint determine a unique segment? ---- */

function unambig() // does the selected waypoint determine a unique segment? 
{ var s0=selected[0],s1=selected[1] ; 
  if(segments.length==1) return 1 ; 
  if( ( s0==segments.length-1 || s1!=segments[s0].data.length-1 ||
        ! segments[s0].data[s1].pos.equals(segments[s0+1].data[0].pos) ) && 
      ( s0==0 || s1!=0 ||
        ! segments[s0].data[s1].pos.equals
          (segments[s0-1].data[segments[s0-1].data.length-1].pos) ) )
    return 1 ; 
  else return 0 ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- walkto --------------------------------- */

// draw a selection point (and possibly an info box) at [s0,s1], bringing up
// a wpinfo window if flag = 0 or a selinfo window if flag = 1 

function walkto(s0,s1,flag) 
{ var datum = segments[s0].data[s1] , pos = datum.pos ; 
  if(flag==undefined) flag = 0  ;
  selected = [ s0,s1 ] ;
  if(overviewing) return highlight() ; 
  map.panToBounds(new google.maps.LatLngBounds(pos,pos)) ; 
  drawsel(0,[s0,s1]) ; 
  if(flag||(datum.type==null&&datum.photo.length==0)) 
  { if(flag==1) wpinfo() ; 
    else if(flag==2) seginfo() ; 
    else if(flag==3) highlight() ; 
    return ; 
  }
  infowindow.open(walktodiv(datum),pos,'walking') ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------- keystroke handler  ---------------------------- */

function keystroke(e)
{ var s0=selected[0],s1=selected[1],slast,flag ;

  if(e.keyCode==16) { shiftkey(1) ; return ; } 
  if(e.keyCode==40&&overviewing==0) 
  { map.panTo(segments[s0].data[s1].pos) ; return ; } 

  flag = infowindow.close() ;
  if(flag=='highlight') flag = 3 ; 
  else if(flag=='wpinfo') flag = 1 ; 
  else flag = 0 ; 

  if(e.keyCode==32) { selclick() ; return ; } // space
  if(overviewing) 
  { if(e.keyCode==70) enterfullscreen() ; 
    if(flag!=3||(e.keyCode!=39&&e.keyCode!=37&&e.keyCode!=8&&e.keyCode!=46)) 
      return ; 
  }
  if(e.keyCode==13) // return 
  { if(dragging) undraggit() ; else if(s0>=0) draggit(0) ; return ; } 
  if(dragging) return ; 

  if(e.keyCode==8||e.keyCode==46) // delete/backspace
  { e.preventDefault() ; 
    if(!e.shiftKey&&(segments.length>1||segments[0].data.length>1)) wpdel() ;
    else if(e.shiftKey&&binbtn.active) discard() ; 
    return ; 
  }
  if(e.keyCode==9) { e.preventDefault() ; inswp(1) ; return ; } // tab

  if(e.keyCode==39) // forwards
  { e.preventDefault() ;
    if(e.shiftKey) { s1 = 0 ; s0 += 1 ; if(s0==segments.length) s0 = 0 ; }
    else if(s1<segments[s0].data.length-1) s1 += 1 ; 
    else { s0 += 1 ; if(s0==segments.length) s0 = 0 ; s1 = 0 ; } 
  }
  else if(e.keyCode==37) // backwards 
  { e.preventDefault() ;  
    if(e.shiftKey) { s1 = 0 ; s0 -= 1 ; if(s0<0) s0 = segments.length-1 ; }
    else if(s1>0) s1 -= 1 ; 
    else 
    { s0 -= 1 ; 
      if(s0<0) s0 = segments.length-1 ; 
      s1 = segments[s0].data.length-1 ; 
    } 
  }
  else return ; 
  walkto(s0,s1,e.shiftKey?2:flag) ;
}
/* -------------------------- shift key handler  ---------------------------- */

function shiftkey(val)
{ shifted = val ; 
  if(mouseopt==1&&map!=null) 
    map.setOptions({draggableCursor:val?'crosshair':'default'}) ;
  if(val==0&&overviewing==0) getalts(200) ;
}
/* --------------------- undraw & redraw segments  -------------------------- */

function undraw(i) 
{ segments[i].route.setMap(null) ; 
  if(segments[i].clickhandler!=null) 
  { google.maps.event.removeListener(segments[i].clickhandler) ;
    segments[i].clickhandler = null ; 
  }
}
function redraw(i) { undraw(i) ; draw(i) ; }

function recolour(i) 
{ if(overviewing) return ; 
  else if(i&1) segments[i].route.setOptions({strokeColor:"#ff9999"}) ; 
  else segments[i].route.setOptions({strokeColor:"#ff0000"}) ; 
}
function obliterate(s0) // undraw route and all labels
{ var i ;
  for(i=0;i<segments[s0].data.length;i++) 
    segments[s0].data[i].setmap(null,null,null,null) ; 
  undraw(s0) ; 
  disconnect(s0-1) ; 
  disconnect(s0) ; 
}
/* ----------------------------- draw segments ------------------------------ */

function draw(i,width)
{ var colour,poly ; 
  if(overviewing) colour = segments[i].colour ; 
  else if(i&1) colour = "#ff9999" ; 
  else colour = "#ff0000" ; 
  if(width==undefined) poly = new linepath(i,-1,0,colour) ;
  else poly = new linepath(i,-1,0,colour,width) ;
  segments[i].route = new google.maps.Polyline(poly) ;
  segments[i].route.setMap(map) ;
  if(segments[i].clickhandler==null)
    segments[i].clickhandler = 
      google.maps.event.addListener(segments[i].route,"click",selpoint) ;
}
/* ----------------------- connect and disconnect segments ------------------ */

function disconnect(i) 
{ if(overviewing||i<0||i>=segments.length-1||segments[i].dots==null) return ;
  segments[i].dots.setMap(null) ; 
  if(segments[i].dothandler!=null) 
  { google.maps.event.removeListener(segments[i].dothandler) ;
    segments[i].dothandler = null ; 
  }
}
function reconnect(i) { disconnect(i) ; connect(i) ; }

function connect(i)
{ if(overviewing||i<0||i>=segments.length-1) return ; 
  var opos = segments[i].data[segments[i].data.length-1].pos ; 
  var npos = segments[i+1].data[0].pos ;
  if(opos.equals(npos)) return ;
  segments[i].dots = new google.maps.Polyline(new dotpath(opos,npos)) ;
  segments[i].dots.setMap(map) ;
  segments[i].dothandler = 
    google.maps.event.addListener(segments[i].dots,"click",selpoint) ;
}
function redrawconnect(s0,s1) 
{ redraw(s0) ; 
  if(s1==0) reconnect(s0-1) ; 
  if(s1=segments[s0].data.length-1) reconnect(s0) ; 
}
/* ---------------------- draw the selection point -------------------------- */

// note: there's no point in allowing clicking on a marker because the 
// event position is always the marker position rather than the click position

function drawsel(opt,selection)
{ if(selection!=undefined) selected = selection ;
  var ind,clen,s0=selected[0],s1=selected[1],pos=segments[s0].data[s1].pos ;
  if(opt) reprofile() ; 
  clen = segments[s0].data.length ; 
  if(clen==1) arrow.rotation = 90 ; 
  else
  { if(s1==clen-1) ind = s1-1 ; else ind = s1 ;
    icons.arrow.rotation = 
      bearing(segments[s0].data[ind].pos,segments[s0].data[ind+1].pos) ;
  }

  if(sel.marker==null) sel.marker = new google.maps.Marker
    ({ position:pos, map:map, cursor:'default', icon:icons.arrow , zIndex:2 }) ;
  else // avoid unnecessary redraws
  { if(icons.arrow.rotation!=sel.orientation) sel.marker.setIcon(icons.arrow) ;
    if(!pos.equals(sel.marker.getPosition())) sel.marker.setPosition(pos) ; 
  }
  sel.orientation = icons.arrow.rotation ; 
  drawxcur(pro,selected) ;

  blackout(penbtn) ;
  if(segments.length>1&&unambig()) blackout(binbtn) ; else greyout(binbtn) ;  
  if(s1!=0&&s1!=clen-1) blackout(scissorsbtn) ; else greyout(scissorsbtn) ;
}
/* ------------- selclick: respond to click of cursor button  --------------- */

function selclick()
{ mouseopt = 1-mouseopt ; 
  infowindow.close() ;  
  if(mouseopt)
  { map.setOptions({draggable:false, draggableCursor:'default'}) ;
    if(overviewing==0) cursorbtn.btn.setAttribute('src',resuri+'hand.png') ; 
    clickhandle = google.maps.event.addListener(map,"click",selpoint) ;
  }
  else
  { map.setOptions({draggable:true, draggableCursor:''}) ;
    if(overviewing==0) cursorbtn.btn.setAttribute('src',resuri+'arrow.png') ; 
    google.maps.event.removeListener(clickhandle) ;
  }
}
/* -------------------------------------------------------------------------- */

function genhead(uri,key)
{ if(uri==undefined||uri==null)  
    resuri = 'http://www.masterlyinactivity.com/routemaster/resources/' ;
  else
  { resuri = uri + '/' ;
    document.write('<script src="http://maps.google.com/maps/api/js?' +
                    ((key==null||key==undefined)?'':('key='+key+'&')) +
                   'libraries=geometry"></scr' + 'ipt>') ;
  }
  document.write
  ('<script src="' + resuri + 'dms.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'vector3d.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'latlon-ellipsoidal.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'utm.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'osgridref.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'FileSaver.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'Blob.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'tcxlib.js"></scr' + 'ipt>' +
   '<script src="' + resuri + 'routemasterlib.js"></scr' + 'ipt>' +
   '<script src="http://www.masterlyinactivity.com/pixlib.js"></scr' + 'ipt>' +
   '<style type="text/css">html, body {width: 100%; height: 100%}' +
       'body {margin:0px}a:link{color:#66aaaa}' + 
       'a:visited{color:#cc3388}a:active{color:#404040}' +
       '</style><title>Routemaster</title>' + 
   '<link rel="shortcut icon" href=' + resuri + 'bus.gif>' ) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*                FUNCTIONS TO GENERATE THE INITIAL MAP                       */
/* -------------------------------------------------------------------------- */

function genpage() 
{ var thispage=document.URL,xhttp,quotind,plusind,listxhttp,div,x,track,ll ;
  imginfo = new listinfo() ; 
  imgdiv = null ; 

  window.onload = function() 
  { window.addEventListener("beforeunload",function(e) 
    { var msg = unsavedmsg(0) ; 
      if(msg==null) return undefined ; 
      (e || window.event).returnValue = msg ; //Gecko + IE
      return msg ; //Gecko + Webkit, Safari, Chrome etc. (msg is ignored by ffx)
    } ) ;
  } ;
  window.onfocus = function() { shiftkey(0) ; } ; 

  body = document.getElementsByTagName("body")[0] ;
  while(body.childNodes.length>0) 
    body.removeChild(body.childNodes[body.childNodes.length-1]) ;
  mapdiv = document.createElement('div') ; 
  mapdiv.setAttribute('id','map') ; 
  mapdiv.setAttribute('style','width:100%;height:100%;position:absolute') ; 
  body.appendChild(mapdiv) ;

  if((quotind=thispage.indexOf('?'))>=0)
  { track = thispage.substring(quotind+1) ; 
    if(track.substring(0,6)=='track='&&(plusind=track.indexOf('&list='))>0) 
    { ll = track.substring(plusind+6) ; track = track.substring(6,plusind) ; }
    else if(track.substring(0,5)=='list=')
    { ll = track.substring(5) ; track = null ; }
    else if(track.substring(0,6)=='track=') 
    { track = track.substring(6) ; ll = null ; }
    else if((plusind=track.indexOf('+'))>0) 
      location.href = thispage.substring(0,quotind+1) + 'track=' +
            track.substring(0,plusind) + '&list=' + track.substring(plusind+1) ; 
    else if(track.substring(track.length-3)=='.js') 
      location.href = thispage.substring(0,quotind+1) + 'list=' + track ;
    else location.href = thispage.substring(0,quotind+1) + 'track=' + track ;
      
    if(ll!=null) getlist(ll,'uri') ;
    if(track!=null)
    { xhttp = new XMLHttpRequest() ;
      xhttp.onreadystatechange = function() 
      { if(xhttp.readyState==4) 
        { if(xhttp.status==200)
          { x = parser.parseFromString(xhttp.responseText,"application/xml") ;
            render(x,track,0,'uri') ; 
          }
          else alert("Unable to read "+track+": error code "+xhttp.status) ;
        }
      }
      xhttp.open("GET",track,true) ;
      xhttp.send() ;
      return ;
    }
  }

  // come here if we need to browse the filesystem for input route
  mapdiv.appendChild(filedialogue(0)) ; 
  div = blurbdiv(resuri) ;
  div.setAttribute('style','font-family:helvetica;margin:4px;'+
                           'border-top:solid 1px silver;padding-top:2px') ; 
  mapdiv.appendChild(div) ; 
  div = helpdiv(resuri,1) ; 
  div.setAttribute('style','font-family:helvetica;margin:4px;font-size:90%') ;
  mapdiv.appendChild(div) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------- getlist --------------------------------- */

function getlist(uri,imgtype) 
{ var xhttp = new XMLHttpRequest(),list=null,sizes=null,pixpage=null,i,r ; 
  var imagedir=null,thumbshape = [] ;
  imginfo.status = 'waiting' ; 
  imginfo.type = imgtype ; 

  xhttp.onreadystatechange = function() 
  { if(xhttp.readyState==4)
    { if(xhttp.status!=200)
      { alert("Unable to read "+uri+": error code "+xhttp.status) ; return ; }
      eval(xhttp.responseText) ; 
      imginfo.uri = uri ; 
      imginfo.list = list ; 
      imginfo.sizes = sizes ; 
      imginfo.pixpage = reluri(uri,pixpage) ; 
      imginfo.thumbind = thumbind(sizes) ;
      setthumbshape(list,sizes,thumbshape,reluri(uri,imagedir)) ;
      for(i=0;i<list.length;i++) if(list[i].retpage!=undefined)
        list[i].retpage = reluri(uri,list[i].retpage) ;
      imginfo.status = 'ready' ; 
    }
  }
  xhttp.open("GET",uri,true) ;
  xhttp.send() ;
}
/* ----------------------------- file dialogue ------------------------------ */

function filedialogue(overwrite)
{ var input = document.createElement('input') ; 
  var para = document.createElement('p') ; 
  para.appendChild(document.createTextNode
                             ('\u00a0\u00a0\u00a0Select TCX/GPX file: ')) ; 
  input.setAttribute('type','file') ; 
  input.setAttribute('accept','.tcx,.gpx') ; 
  input.addEventListener('change',function(e)
  { reader = new FileReader() ;
    reader.onload = function(e) 
    { var xmldoc = parser.parseFromString(reader.result,"application/xml") ;
      render(xmldoc,input.files[0].name,overwrite,'file') ; 
    } 
    reader.readAsText(input.files[0]) ;	
  } ) ;
  para.appendChild(input) ; 
  return para ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------- set up the map and buttons ------------------------- */

function render(xmldoc,filename,overwrite,origin) 
{ var i,opts,centre,lat,lon,minlon,maxlon,minlat,maxlat,newseg,s0,d ;
  var colours,segno,istcx ; 

  infowindow.close() ;
  document.onkeydown = keystroke ;
  document.onkeyup = function(e) { if(e.keyCode==16) shiftkey(0) ; } ;
  xmlfile = filename ;

  // read data
  i = filename.length ; 
  if(filename.substring(i-4,i).toLowerCase()=='.tcx') istcx = 1 ; 
  else if(filename.substring(i-4,i).toLowerCase()=='.gpx') istcx = 0 ; 
  else { alert(filename+' is neither .tcx nor .gcx') ; throw '' ; }

  if(istcx) newseg = readtcx(xmldoc,map,selpoint,selpoint,labelcycle) ; 
  else newseg = readgpx(xmldoc,map,selpoint,labelcycle) ;
  if(newseg.segments.length==0||newseg.segments[0].length==0) 
  { alert('no data returned') ; return ; }
  if(newseg.segments.length>1&&setbtn!=null)
  { alert('trying to add a multitrack index... not permitted') ; return ; } 

  // check and process photo list
  if(imginfo.type!='uri') for(i=0;i<newseg.segments.length;i++)
    if(newseg.props[i].list!=null)
  { if(imginfo.uri!=null&&newseg.props[i].list!=imginfo.uri)
    { alert('inconsistent photo lists: ' + imginfo.uri + ' and ' + 
            newseg.props[i].list) ; 
      return ; 
    }
    imginfo = new listinfo() ; 
    getlist(newseg.props[i].list,'tcx') ; 
  }

  if(overwrite)
  { for(i=0;i<segments.length;i++) obliterate(i) ; 
    unprofile() ; 
    if(sel.marker!=null) sel.marker.setMap(null) ;
    segments = [] ; 
    if(imginfo.type=='tcx') imginfo = new listinfo() ; 
  } 

  s0 = segments.length ; 
  if(s0==0)
  { sel = { marker:null, orientation: null } ; 
    pending = [] ; 
    xpending = [] ; 
    actions = [] ; 
    unsavedchanges = [] ; 
    nactions = dragging = 0 ; 
    pro = starsdiv = null ; 
    routeprops = new propstype() ;
  }

  if(newseg.segments.length>1) 
  { overviewing = 1 ; 
    colours = gencolours(newseg.segments.length) ; 
    routeprops.stars = -1 ; 
  } 
  if(istcx&&routeprops.title==null&&newseg.title!=null) settitle(newseg.title) ;

  // process the new segments
  for(i=0;i<newseg.segments.length;i++)
  { newseg.props[i].source = [ filename , origin ] ; 
    if(routeprops.stars==null) routeprops.stars = newseg.props[i].stars ;
    if(routeprops.overview==null) 
      routeprops.overview = newseg.props[i].overview ;
    if(routeprops.title==null||routeprops.title=='Untitled Route')
      if(newseg.props[i].title!=null) settitle(newseg.props[i].title) ; 
    segments.push(new genseg(newseg.segments[i],newseg.props[i])) ; 
    if(overviewing) segments[segments.length-1].colour = colours[i] ;
    actions[nactions++] = 
      [ 'load',s0+i,newseg.segments[i].slice(),newseg.props[i] ] ;
  }
  if(routeprops.title==null) settitle('Untitled Route') ; 

  if(!newseg.props[0].optim.already&&!overviewing) 
    optimaction(segments.length-1,defparms,0) ; 
  if(overviewing) 
  { for(d='',i=0;i<newseg.segments.length;i++) if(newseg.props[i].title!=null)
    { if(d!='') d += ' | ' ; d += newseg.props[i].title ; }
    if(d!=null) routeprops.desc = d ; 
  }
  else { routeprops.desc = newseg.props[0].desc ; getalts(100) ; }
  if(routeprops.desc!=null) setdesc(routeprops.desc) ;

  // find max and min lat and long - have to look at all segs, not just
  // newly loaded to avoid google's repeatedly adding a margin 
  for(maxlat=null,segno=0;segno<segments.length;segno++)
    for(i=0;i<segments[segno].data.length;i++)
  { lat = segments[segno].data[i].pos.lat() ; 
    lon = segments[segno].data[i].pos.lng() ; 
    if(maxlat==null||lon<minlon) minlon = lon ; 
    if(maxlat==null||lon>maxlon) maxlon = lon ; 
    if(maxlat==null||lat<minlat) minlat = lat ; 
    if(maxlat==null||lat>maxlat) maxlat = lat ; 
  }

  if(s0==0)
    centre = new google.maps.LatLng((minlat+maxlat)/2,(minlon+maxlon)/2) ;

  if(map==null) // all this only done on first call
  { opts = { zoom: 22,
             center: centre,
             scaleControl: true,
             rotateControl: false,
             streetViewControl: false,
             mapTypeId: overviewing?
                google.maps.MapTypeId.ROADMAP:google.maps.MapTypeId.TERRAIN,
             disableDoubleClickZoom: true,
             styles: [ { "featureType": "poi", 
                         "stylers": [{ "visibility": "off" }]
                        } ],
             mapTypeControl:true,
             mapTypeControlOptions: 
               { style:google.maps.MapTypeControlStyle.HORIZONTAL_BAR }, 
             mapTypeIds: [ google.maps.MapTypeId.ROADMAP,
                           google.maps.MapTypeId.TERRAIN,
                           google.maps.MapTypeId.SATELLITE
                         ]
           } ;

    map = new google.maps.Map(mapdiv,opts) ;

    // set up buttons
    if(overviewing==0)
    { setbtn = genbutton('settings') ;
      cursorbtn = genbutton('cursor') ;
      cursorbtn.ui.addEventListener('click',selclick) ;
      scissorsbtn = genbutton('scissors') ;
      binbtn = genbutton('bin') ;
      penbtn = genbutton('pen') ;
      undobtn = genbutton('undo') ;
      redobtn = genbutton('redo') ;
      dlbtn = genbutton('dl') ;
    }
    selclick() ;
  }

  map.fitBounds(new google.maps.LatLngBounds
                             (new google.maps.LatLng(minlat,minlon),
                              new google.maps.LatLng(maxlat,maxlon))) ; 

  for(segno=s0;segno<segments.length;segno++)
    for(i=0;i<segments[segno].data.length;i++) 
      segments[segno].data[i].setmap(map,selpoint,selpoint,labelcycle) ;

  if(nactions>1) donesomething() ; // specifically, done loading & optimisation
  else actions.length = nactions ; // load with no optimisation hence no undo

  if(s0==0) { selected = [0,0] ; if(overviewing==0) drawsel(1) ; } 
  else greyout(dlbtn) ; 
  for(segno=s0;segno<segments.length;segno++)
  { draw(segno) ; connect(segno-1) ; }
  connect(segments.length-1) ; 
  reprofile() ; 
}
/* ------------------------------- settitle --------------------------------- */

function settitle(newtitle) 
{ routeprops.title = newtitle ; 
  var h = document.getElementsByTagName('title') ;
  if(h.length==0) h = document.createElement('title') ;
  else { h = h[0] ; while(h.firstChild) h.removeChild(h.firstChild) ; }
  h.appendChild(document.createTextNode(newtitle)) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------- setdesc --------------------------------- */

function setdesc(newdesc) 
{ routeprops.desc = newdesc ; 
  var i , d='' , m = document.getElementsByTagName('meta') ;
  if(overviewing==0&&routeprops.stars!=null) for(i=0;i<=routeprops.stars;i++)
  { if(i<routeprops.stars) d += '\u2605' ; else d += ' | ' ; }
  for(i=0;i<m.length&&m[i].getAttribute('name')!='description';i++) ;
  if(i<m) { m[i].setAttribute('content',newdesc) ; return ; }
  m = document.createElement('meta') ;
  m.setAttribute('name','description') ; 
  m.setAttribute('content',d+newdesc) ; 
  document.getElementsByTagName('head')[0].appendChild(m) ;
}
/* ------------------------------- retitle ---------------------------------- */

function retitle(opt) 
{ var newval , msg , field = (opt=='title'?'title':'desc') ; 
  var oldval = routeprops[field] ;
  infowindow.close() ; 
  if(oldval==null) msg = 'Add ' + opt ; else msg = 'Modify ' + opt ;
  if(opt=='title') msg += ' (max 15 chars):' ; else msg += ':' ;
  newval = window.prompt(msg,oldval==null?'':oldval) ;
  if(newval==null) return ; 
  if(opt=='title') newval = newval.substring(0,15) ; 
  if(newval==oldval) return ; 
  if(opt=='title') settitle(newval) ; else setdesc(newval) ; 
  actions[nactions++] = [ 'edit'+opt , oldval , newval ] ; 
  donesomething() ;
  routeinfo() ; 
}
/* ------------------------------- restars ---------------------------------- */

function restars(oldstars,newstars) 
{ actions[nactions++] = [ 'stars' , oldstars , newstars ] ; 
  donesomething() ;
  starsline(routeprops.stars=newstars,1) ; 
}
/* ------------------------------- genbutton -------------------------------- */

function genbutton(name)
{ var u,v,w,b,g,k,h,div=document.createElement('div'),act ;
  u = document.createElement('div') ;
  u.style.backgroundColor = '#ffffff' ;
  u.style.border = '2px solid #ffffff' ;
  u.style.borderRadius = '3px' ;
  u.style.boxShadow = '0 2px 6px rgba(0,0,0,.3)' ;
  if(name=='dl'||name=='settings'||name=='cursor') u.style.cursor = 'pointer' ;
  else u.style.cursor = 'default' ;
  u.style.marginBottom = '12px' ;
  if(name!='dl') u.style.marginRight = '4px' ;
  u.style.textAlign = 'center' ;
  div.appendChild(u) ;

  if(name=='scissors') { h = snip ; div.index = 3 ; } 
  else if(name=='bin') { h = discard ; div.index = 4 ; }
  else if(name=='pen') { h = labelprompt ; div.index = 5 ; }
  else if(name=='undo') { h = undo ; div.index = 6 ; }
  else if(name=='redo') { h = redo ; div.index = 7 ; }
  else if(name=='dl') { h = function() { dl(0) ; }  ; div.index = 8 ; }
  else if(name=='settings') { h = popup ; div.index = 1 ; }
  else if(name=='cursor') { h = null ; div.index = 2 ; }
  g = greybtn(resuri,name) ;
  k = blackbtn(resuri,name) ;
  if(name=='dl'||name=='settings'||name=='cursor') b = buttonimg(k) ;
  else b = buttonimg(g) ;
  u.appendChild(b) ;

  map.controls[google.maps.ControlPosition.BOTTOM_CENTER].push(div) ;

  if(name=='dl'||name=='settings') u.addEventListener('click',h) ; 
  if(name=='dl'||name=='settings') act = 1 ; else act = 0 ; 

  return { ui:u , btn:b , active:act , greyimg:g , blackimg:k , handler:h } ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*                             OPTIMISATION                                   */
/* -------------------------------------------------------------------------- */

function optimaction(segno,parms,force)
{ var s = segments[segno], result = optimise(s.data,parms) , loadno ; 
  var ndel = s.data.length - result.length ; 
  for(loadno=nactions-1;loadno>=0&&actions[loadno][0]!='load';loadno--) ; 
  if(loadno<0||(force==0&&ndel<s.data.length/2)||ndel==0) return 0 ; 
  actions[loadno][3].optim.ndel = ndel ; 
  actions[nactions++] = [ 'optimise' , segno , parms ] ; 
  segments[segno] = new genseg(result,segments[segno].props) ;
  actions[loadno][3].optim.parms = { tol: parms.tol , 
                                     maxsep: parms.maxsep , 
                                     wppenalty: parms.wppenalty , 
                                     vweight: parms.vweight 
                                   } ; 
  return 1 ; 
}
/* -------------------------------------------------------------------------- */

function optimprompt()
{ var msg = 'Enter 4 optimisation parms (tol, maxsep, wppenalty and vweight)' ; 
  var parmstr = defparms.tol + ' ' + 
                defparms.maxsep.toFixed(0) + ' ' +
                defparms.wppenalty.toFixed(0) + ' ' +
                defparms.vweight.toFixed(1) ;
  var parms,i ; 
  infowindow.close() ;  

  for(i=0;;i++)
  { newparms = prompt(msg,parmstr) ; 
    if(newparms==null) return ;
    if(newparms=='') { parms = defparms ; break ; }
    newparms = newparms.split(' ') ;
    if(newparms.length==0) { parms = defparms ; break ; }
    parms = { tol: parseFloat(newparms[0]) , 
              maxsep: parseFloat(newparms[1]) , 
              wppenalty: parseFloat(newparms[2]) , 
              vweight: parseFloat(newparms[3]) } ; 
    if( isNaN(parms.tol)==0 && isNaN(parms.maxsep)==0 && 
        isNaN(parms.wppenalty)==0 && isNaN(parms.vweight)==0 ) break ; 
    if(i==0) msg = '*** Illegal parms ***\n' + msg ; 
  }
  if(optimaction(segments.length-1,parms,1)) 
  { donesomething() ; draw(segments.length-1) ; } 
  routeinfo() ; 
}  
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function popup()
{ infowindow.close() ;
  var d=cogwheelmenu(dragging,pro!=null&&pro.prodiv!=null,routeprops.overview) ;
  infowindow.open(d,getbtnpos(0),'settings') ; 
}
/* ------------------------------- calwork --------------------------------- */

function calwork(s0,y)
{ var i,s1 ; 
  for(s1=0;s1<segments[s0].data.length;s1++)
    if(segments[s0].data[s1].h!=null) segments[s0].data[s1].h += y ; 
  reprofile() ; 
}
/* ------------------------------ manualcal --------------------------------- */

function manualcal()
{ infowindow.close() ; 
  var x,y,s0=selected[0] ;
  x = prompt('Enter offset in metres to add to altidudes:') ;
  if(x==null) return ; 
  y = parseFloat(x) ; 
  if(isNaN(y)) { alert(x+' is not a number') ; return ; }
  calwork(s0,y) ; 
  done(['recal',s0,y]) ; 
}  
/* --------------------------------- help ----------------------------------- */

function help() 
{ infowindow.close() ; infowindow.open(helpdiv(resuri),getbtnpos(0),'help') ; }

/* --------------------------------- wpdel ---------------------------------- */

function wpdelwork(s0,s1)
{ var i,response=segments[s0].data[s1],clen=segments[s0].data.length ;
  response.setmap(null,null,null,null) ;
  for(i=s1;i<clen-1;i++) segments[s0].data[i] = segments[s0].data[i+1] ;
  segments[s0].data.length = clen-1 ; 
  selected = [s0,s1] ; 
  if(s1==segments[s0].data.length) selected[1] -= 1 ;  
  redrawconnect(s0,s1) ; 
  drawsel(1) ; 
  return response ;
}
function wpdel()
{ var s0=selected[0],s1=selected[1],i ;
  var flag = infowindow.close() ; 
  done(['wpdel',s0,s1,wpdelwork(s0,s1)]) ; 
  if(flag=='wpinfo') wpinfo() ; 
}
/* --------------------------------- revseg --------------------------------- */

function revsegwork(s0)
{ var i,s=segments[s0],j,x,len=s.data.length ;
  disconnect(s0-1) ; disconnect(s0) ; 
  for(i=0;i<len/2;i++)
  { j = (len-1) - i ; 
    x = s.data[i] ; s.data[i] = s.data[j] ; s.data[j] = x ; 
  }
  for(i=0;i<s.data.length;i++) 
    if(s.data[i].type=='Right') s.data[i].settype('Left') ;
    else if(s.data[i].type=='Left') s.data[i].settype('Right') ;

  if(s0==selected[0]) selected[1] = (len-1) - selected[1] ; 
  connect(s0-1) ; connect(s0) ; 
  drawsel(1) ; 
}
/* -------------------------------------------------------------------------- */

function revseg()
{ infowindow.close() ; 
  revsegwork(selected[0]) ; 
  done(['revseg',selected[0]]) ; 
}
/* -------------------------------------------------------------------------- */

function addload(overwrite)
{ var msg ; 
  infowindow.close() ; 
  if(overwrite) 
  { msg = unsavedmsg(1) ; if(msg!=null) if(!confirm(msg)) return ; }
  infowindow.open(filedialogue(overwrite),getbtnpos(0),'addload') ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*    DRAGGING A (POSSIBLY NEWLY INSERTED) WAYPOINT IS QUITE A LOT OF WORK    */
/* -------------------------------------------------------------------------- */

function insert(s0,s1,n)
{ var i ;
  for(i=segments[s0].data.length+n-1;i>s1;i--)
    segments[s0].data[i] = segments[s0].data[i-n] ;
  for(i=0;i<n;i++) segments[s0].data[s1+i] = new datatype(null,null) ;  
}
/* --------------------------------- inswp ---------------------------------- */

function inswp(dir)
{ var s0=selected[0],s1=selected[1],bounds,del,pos,data=segments[s0].data ;
  var len = data.length ;
  if(len==1) pos = data[0].pos ;

  if(dir>=0) s1 = selected[1] += 1 ; 
  insert(s0,s1,1) ;  
  if(len==1)
  { bounds = map.getBounds() ;
    del = bounds.getNorthEast().lng() - bounds.getSouthWest().lng() ; 
    pos = new google.maps.LatLng(pos.lat(),pos.lng()+dir*del/10) ; 
  }
  else if(s1==0) pos = interp(data[2].pos,data[1].pos,1.5) ; 
  else if(s1<len) pos = interp(data[s1-1].pos,data[s1+1].pos,0.5) ;
  else pos = interp(data[s1-2].pos,data[s1-1].pos,1.5) ;
  data[s1].setpos(pos) ; 
  draggit(1) ; 
}
/* -------------------------------- draggit --------------------------------- */

// draggit makes the current waypoint draggable

var l1,l2,startpos,seg0,seg1,seg2,colour,inserted ; 

function draggit(insparm)
{ var s0=selected[0],s1=selected[1],start,end,i,len=segments[s0].data.length ;
  startpos = segments[s0].data[s1].pos ;
  inserted = insparm ; 
  infowindow.close() ; 
  greyout(scissorsbtn) ;
  greyout(binbtn) ;
  greyout(penbtn) ;
  greyout(undobtn) ;
  greyout(redobtn) ;
  greyout(dlbtn) ;
  map.panToBounds(new google.maps.LatLngBounds(startpos,startpos)) ;

  sel.marker.setMap(null) ; 
  sel.marker = new google.maps.Marker(
                  { position: segments[s0].data[s1].pos,
                    map: map,
                    cursor: 'default',
                    icon: icons.concircle ,
                    draggable: true ,
                    zIndex: 2
                  } ) ;

  if(s0&1) colour = "#ff9999" ; else colour = "#ff0000" ; 
  segments[s0].route.setMap(null) ;
  if(segments[s0].clickhandler!=null) 
  { google.maps.event.removeListener(segments[s0].clickhandler) ;
    segments[s0].clickhandler = null ; 
  }

  seg0 = seg2 = null; 
  if(s1>1)
  { seg0 = new google.maps.Polyline(new linepath(s0,0,s1,colour)) ;
    seg0.setMap(map) ;
  }

  if(s1==0) start = 0 ; else start = s1-1 ; 
  if(s1==len-1) end = s1+1 ; else end = s1+2 ; 
  seg1 = new google.maps.Polyline(new linepath(s0,start,end,colour)) ;
  seg1.setMap(map) ;

  if(s1<segments[s0].data.length-2)
  { seg2 = new google.maps.Polyline(new linepath(s0,s1+1,len,colour)) ;
    seg2.setMap(map) ;
  }

  l1 = google.maps.event.addListener(sel.marker,'drag',function()
  { segments[s0].data[s1].setpos(this.getPosition()) ; 
    seg1.setMap(null) ;
    seg1 = new google.maps.Polyline(new linepath(s0,start,end,colour)) ;
    seg1.setMap(map) ;
    if(s1==0&&s0>0) { disconnect(s0-1) ; connect(s0-1) ; }
    if(s1==len-1&&s0<segments.length-1) { disconnect(s0) ; connect(s0) ; }
  } ) ;
 
  dragging = 1 ; 
}  
/* ------------------------------- undraggit -------------------------------- */

// undraggit is invoked by [return] to terminate waypoint dragging

function undraggit()
{ var s0=selected[0],s1=selected[1],i,s1dash,pos=segments[s0].data[s1].pos ; 
  var xpos ; 
  google.maps.event.removeListener(l1) ;
  dragging = 0 ; 
  if(seg0!=null) seg0.setMap(null) ;
  seg1.setMap(null) ;
  if(seg2!=null) seg2.setMap(null) ;
  segments[s0].route = new google.maps.Polyline(new linepath(s0,-1,0,colour)) ;
  segments[s0].route.setMap(map) ;
  segments[s0].data[s1].h = null ;
  getalts(100) ; 

  sel.marker.setMap(null) ; 
  sel.marker = null ; // force a redraw
  drawsel(1) ; 
  if(inserted||dist(startpos,pos)>5) 
    done(['move',s0,s1,startpos,pos,inserted]) ; 
  if(segments.length==1) blackout(dlbtn) ; 
}
/* -------------------------------------------------------------------------- */

function seginfo()
{ var pos = segments[selected[0]].data[selected[1]].pos ;
  infowindow.close() ;
  infowindow.open(seginfodiv(segments,selected[0]),pos,'seginfo') ; 
}
/* -------------------------------------------------------------------------- */

function deltimes()
{ var s0,s1,task=[] ;
  for(s0=0;s0<segments.length;s0++) for(s1=0;s1<segments[s0].data.length;s1++)
    if(segments[s0].data[s1].t!=null)
  { task.push([s0,s1,segments[s0].data[s1].t]) ; 
    segments[s0].data[s1].t = null ; 
  }
  infowindow.close() ;  
  done(['deltimes',task]) ;
}  
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------- interpolate extra points ----------------------- */

function extrapts(opt)
{ var s0,s1,sep,data,n,opos,npos,i,lambda ;
  var task = [ 'extra' , selected[0] , selected[1] ] ;
  infowindow.close() ; 

  for(s0=0;s0<segments.length;s0++) 
    for(data=segments[s0].data,s1=1;s1<data.length;s1++)
      if((sep=dist(opos=data[s1-1].pos,npos=data[s1].pos))>100) 
  { n = Math.floor(sep/95) ;
    insert(s0,s1,n) ; 
    for(i=0;i<n;i++) 
    { lambda = (i+1) / (n+1) ; 
      data[s1+i].setpos(new google.maps.
                          LatLng(lambda*npos.lat()+(1-lambda)*opos.lat(),
                                 lambda*npos.lng()+(1-lambda)*opos.lng())) ;
    }
    if(selected[0]==s0&&s1<=selected[1]) selected[1] += n ; 
    task.push([s0,s1,data.slice(s1-1,s1+n+1)]) ; 
    s1 += n ;
  }

  getalts(1) ; 
  done(task) ; 
  routeinfo() ; 
}
/* -------------------------------------------------------------------------- */
/*                 FUNCTIONS FOR HANDLING THE ALTITUDE PROFILE                */
/* -------------------------------------------------------------------------- */

function drawprofile()
{ infowindow.close() ; 
  if((pro=procoords(segments))==null) return ; 
  drawpro(pro) ;
  body.appendChild(pro.prodiv) ; 
  body.appendChild(pro.curdiv) ;
  drawxcur(pro,selected) ;
} 
/* ------------------------------- unprofile -------------------------------- */

function unprofile()
{ var i,match,node ; 
  infowindow.close() ; 
  if(pro==null||pro.prodiv==null) return ; 
  pro.curdiv.removeEventListener('click',pro.curhandle) ;
  for(match=0,i=body.childNodes.length-1;match==0&&i>=0;i--)
  { node = body.childNodes[i] ; 
    match = (node==pro.prodiv) ; 
    body.removeChild(node) ; 
  }
  pro = null ; 
} 
function reprofile() 
{ if(pro!=null&&pro.prodiv!=null) { unprofile() ; drawprofile() ; } } 

/* -------------------------------------------------------------------------- */
/*       ROUTEINFO IS A MENU WHICH ITSELF SUPPORTS THE COMBINE FUNCTION       */
/* -------------------------------------------------------------------------- */

function routeinfo() 
{ infowindow.close() ; 
  infowindow.open(routediv(routeprops),getbtnpos(0),'routeinfo') ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------------- combine1 --------------------------------- */

function combine1(sa,sb)
{ var i,ca,cb,calen,cblen,la=null,lb=null,cdup=0 ; 
  undraw(sb) ; 
  disconnect(sb-1) ; 
  calen = segments[sa].data.length ;
  cblen = segments[sb].data.length ;
  cb = segments[sb].data[0].pos ; 
  cdup = ( ca = cb.equals(segments[sa].data[calen-1].pos) ) ; 
  if(cdup) 
  { la = segments[sa].data[calen-1] ; 
    lb = segments[sb].data[0] ; 
    segments[sa].data.length = ( calen -= 1 ) ;
  }

  if(selected[0]==sb) selected = [ sa , selected[1]+calen ] ; 
  segments[sa].data = segments[sa].data.concat(segments[sb].data) ; 

  return [ cblen , cdup , la , lb ] ; 
}
function combinework()
{ var task,s0 ;
  for(task=['combine',segments.length],s0=1;s0<segments.length;s0++) 
    task.push(combine1(0,s0)) ;
  segments.length = 1 ; 
  return task ; 
}
/* -------------------------------------------------------------------------- */

function combine()
{ infowindow.close() ; 
  done(combinework()) ; 
  drawsel(1) ; 
  redraw(0) ; 
  blackout(dlbtn) ; 
}  
function recombine()
{ var s0 ;
  for(s0=1;s0<segments.length;s0++) { undraw(s0) ; combine1(0,s0) ;}
  segments.length = 1 ; 
  drawsel(1) ;  
  redraw(0) ;
  blackout(dlbtn) ; 
}
/* -------------------------------------------------------------------------- */

//  combine returns [ cblen , cdup , la , lb ] ; 

function uncombine(task)
{ var i,j,llen,flag,subtask ; 

  for(flag=0,s0=task[1]-1,i=task.length-1;i>=2;i--,s0--)
  { subtask = task[i] ; 
    cblen = subtask[0] ; 
    cdup = subtask[1] ; 
    llen = segments[0].data.length ;
    segments[s0] = { data: segments[0].data.slice(llen-cblen,llen) , 
                     route: null , 
                     clickhandler: null
                   } ;
    llen = segments[0].data.length = llen+cdup-cblen ;
    if(cdup) 
    { segments[0].data[llen-1] = subtask[2] ; 
      segments[s0].data[0] = subtask[3] ; 
    }
    if(flag==0&&selected[1]>=llen)
    { selected = [ s0 , selected[1]-llen ] ; flag = 1 ; } 
  }
  drawsel(1) ; 
  undraw(0) ; 
  for(i=0;i<segments.length;i++) { draw(i) ; connect(i) ; } 
  greyout(dlbtn) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*           WPINFO IS A MENU GIVING ACCESS TO THE SETALT FUNCTION            */
/* -------------------------------------------------------------------------- */

function wpinfo() 
{ infowindow.close() ; 
  infowindow.open
    (wpinfodiv(),segments[selected[0]].data[selected[1]].pos,'wpinfo') ; 
}
/* -------------------------------- setalt ---------------------------------- */

function setalt(edit)
{ infowindow.close() ; 
  var s0=selected[0],s1=selected[1],x,y=null,oldalt ; 
  oldalt = segments[s0].data[s1].h.toFixed(0) ;
  if(edit) x = prompt('Enter altitude (m):',oldalt) ;
  else x = prompt('Enter altitude (m):') ;
  if(x==null) return ; 
  if(x!=''&&isNaN(y=parseFloat(x))) { alert(x+' is not a number') ; return ; }
  if(y==null&&oldalt==null) return ; 
  if(y!=null&&Math.abs(y-oldalt)<0.1) return ; 
  segments[s0].data[s1].h = y ; 
  done(['setalt',s0,s1,oldalt,y]) ; 
  reprofile() ; 
  wpinfo() ; 
}  
/* -------------------------------------------------------------------------- */
/*   THE LABELS ARE ACCESSED FROM THE PEN BUTTON OR BY CLICKING ON THE MAP    */
/* -------------------------------------------------------------------------- */

function labelprompt()
{ var oldcaption='',oldtype,type='Generic',s0=selected[0],s1=selected[1] ;
  var str , flag = (infowindow.close()=='wpinfo') ; 
  var datum = segments[s0].data[s1] ;
  
  oldtype = datum.type ; 
  if(oldtype!=null) oldcaption = datum.marker.title ; 
  if(oldcaption==null) oldcaption = '' ;
  if(oldtype!=null) type = oldtype ;
  if(oldtype==null) str = 'Enter' ; else str = 'Modify or delete' ; 
  var caption = window.prompt(str+' label:',oldcaption) ;

  if(caption==null) { if(flag) wpinfo() ; else walkto(s0,s1) ; return ; } 
  else if(caption=='') type = null ; 
  else caption = caption.substring(0,10) ;
  if(caption==oldcaption) 
  { if(flag) wpinfo() ; else walkto(s0,s1) ; return ; } 

  segments[s0].data[s1].setlabel(type,caption,map,selpoint,labelcycle) ; 
  done(['editlabel',s0,s1,oldcaption,caption,oldtype,type]) ; 
  if(flag) wpinfo() ; else walkto(s0,s1) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------------ labelcycle -------------------------------- */

function labelcycle()
{ var s0,s1,datum,types,caption,flag=(infowindow.close()=='wpinfo') ;  ;
  for(s0=0;s0<segments.length;s0++) for(s1=0;s1<segments[s0].data.length;s1++)
  { datum = segments[s0].data[s1] ;
    if(datum.marker!=this) continue ; 
    types = datum.labelcycle() ;
    caption = datum.marker.title ;
    selected = [s0,s1] ; 
    done(['editlabel',s0,s1,caption,caption,types[0],types[1]]) ; 
    if(flag) wpinfo() ; 
    return ;
  }
}
function photoprompt(e) 
{ var s0=selected[0],s1=selected[1] ;
  if(e!=null) e.preventDefault() ;
  var flag = (infowindow.close()=='wpinfo') ; 
  var datum = segments[s0].data[s1] ;
  var photo = window.prompt('Enter photo name:','') ;

  if(photo!=null&&photo!='') 
  { done(['editphoto',s0,s1,datum.photo.length,null,photo]) ; 
    datum.addphoto(photo,map,selpoint) ; 
  }
  if(flag) wpinfo() ; else walkto(s0,s1) ; 
}
function photoedit(ind)
{ var s0=selected[0],s1=selected[1],i ;
  var flag = (infowindow.close()=='wpinfo') ; 
  var datum = segments[s0].data[s1] ;
  var photo = window.prompt('New photo name:',datum.photo[ind]) ;

  if(photo!=null&&photo!='') for(i=0;i<datum.photo.length;i++)
    if(datum.photo[i]==photo) { photo = null ; break ; }
  if(photo!=null)
  { if(photo=='') photo = null ;
    done(['editphoto',s0,s1,ind,datum.photo[ind],photo]) ; 
    datum.setphoto(ind,photo,selpoint) ; 
  }
  if(flag) wpinfo() ; else walkto(s0,s1) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ----------------------------- display photo ------------------------------ */

var lmove,rmove ; 

function advance(s0,s1,ind)
{ for(ind++;;ind++)
  { if(ind>=segments[s0].data[s1].photo.length) { ind = 0 ; s1 += 1 ; }
    if(s1==segments[s0].data.length) 
    { s0 += 1 ; if(s0==segments.length) return null ; s1 = 0 ; }
    if(ind<segments[s0].data[s1].photo.length) return [s0,s1,ind] ;
  }
}
function retreat(s0,s1,ind)
{ for(ind--;;ind--)
  { if(ind<0) { s1 -= 1 ; ind = null ; }
    if(s1<0)
    { if(s0==0) return null ; else s0 -= 1 ; 
      s1 = segments[s0].data.length-1 ; 
    }
    if(ind==null) ind = segments[s0].data[s1].photo.length - 1 ;
    if(ind>=0) return [s0,s1,ind] ;
  }
}
function prev() { dodisplay(lmove[0],lmove[1],lmove[2],-1) ; }
function backtogps() 
{ document.onkeydown = keystroke ; 
  window.removeEventListener('resize',resize) ; 
  body.removeChild(imgdiv) ; 
  walkto(selected[0],selected[1],0) ;
}
function next() { dodisplay(rmove[0],rmove[1],rmove[2],1) ; }

function display(ind)
{ document.onkeydown = imgwalk ;
  window.addEventListener('resize',resize) ; 
  infowindow.close() ; 
  imgdiv = document.createElement('div') ; 
  imgdiv.setAttribute('style','position:fixed;width:100%;height:100%;'+
                      'left:0;top:0;background:black') ;
  dodisplay(selected[0],selected[1],ind,1) ; 
  body.appendChild(imgdiv) ; 
}
function dodisplay(s0,s1,ind,dir)
{ var phind=findimg(segments[s0].data[s1].photo[ind]) , pre=null ;
  uncaption() ; 
  selected[0] = s0 ; 
  selected[1] = s1 ; 
  lmove = retreat(s0,s1,ind) ;
  rmove = advance(s0,s1,ind) ;
  if(dir<0&&lmove!=null)
    pre = findimg(segments[lmove[0]].data[lmove[1]].photo[lmove[2]]) ;
  else if(dir>=0&&rmove!=null)
    pre = findimg(segments[rmove[0]].data[rmove[1]].photo[rmove[2]]) ;
  if(pre!=null) pre = imginfo.list[pre] ;

  gendisplay(imgdiv,imginfo.list[findimg(segments[s0].data[s1].photo[ind])],
             imginfo.sizes,lmove==null?null:'javascript:prev()',
             'javascript:backtogps()',rmove==null?null:'javascript:next()',
             'GPS track',pre) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------------ image walk -------------------------------- */

function imgwalk(e)
{ e.preventDefault() ; 

  if(e.keyCode==39) { if(rmove!=null) next() ; return ; }
  else if(e.keyCode==37) { if(lmove!=null) prev() ; return ; }
  else if(e.keyCode==40) reduce() ; 
  else if(e.keyCode==38) enlarge() ; 
  else if(e.keyCode==70) enterfullscreen() ; 
  else backtogps() ;
}
/* --------------------------------- photo info ----------------------------- */

function phinfo(i) 
{ infowindow.close() ; 
  var s0=selected[0],s1=selected[1] ; 
  infowindow.open(phdiv(i),segments[s0].data[s1].pos,'phinfo') ; 
}
/* ------------------------- snip: apply scissors  -------------------------- */

function snipwork(s0,s1)
{ var i,k,newlen ; 
  undraw(s0) ; 
  segments.length += 1 ; 
  for(i=segments.length-1;i>s0+1;i--) segments[i] = segments[i-1] ; 

  newlen = segments[s0].data.length - s1 ;
  segments[s0+1] =  new genseg(segments[s0].data.slice(s1),segments[s0].props) ;
  segments[s0+1].dots = segments[s0].dots ;
  segments[s0+1].dothandler = segments[s0].dothandler ;
  segments[s0].dots = segments[s0].dothandler = null ; 

  segments[s0].data.length = s1 + 1 ; 
  segments[s0].data[s1] = 
    new datatype(segments[s0].data[s1].pos,segments[s0].data[s1].h) ; 
  draw(s0) ;
  draw(s0+1) ; 
  for(i=s0+2;i<segments.length;i++) recolour(i) ;
  drawsel(1,[s0+1,0]) ; 
  greyout(dlbtn) ; 
}
function snip()
{ var i,s0=selected[0],s1=selected[1] ; 
  infowindow.close() ; 
  done(['snip',s0,s1]) ; 
  snipwork(s0,s1) ; 
}
/* ------------------------ discard: bin a segment  ------------------------- */

function binwork(s0)
{ var i ; 
  obliterate(s0) ; 
  for(i=s0;i<segments.length-1;i++) segments[i] = segments[i+1] ; 
  segments.length -= 1 ; 

  for(i=s0;i<segments.length;i++) recolour(i) ;
  connect(s0-1) ;

  selected[1] = 0 ; 
  if(selected[0]==segments.length) selected[0] = 0 ; 
  drawsel(1) ; 
  if(segments.length==1) blackout(dlbtn) ; 
}
function discard()
{ var i,s0=selected[0] ; 
  infowindow.close() ;  
  done(['bin',s0,segments[s0]]) ; 
  binwork(s0) ; 
}
/* -------------------------------------------------------------------------- */

function actiontype(x)
{ if( x=='snip'||x=='combine'||x=='interpolate'
   || x=='optimise'||x=='load' ) return 0 ; else return 1 ;
}
function done(something) 
{ if( nactions>0 && unsavedchanges.length>0 && something[0]=='editlabel'
   && actions[nactions-1][0]==something[0]
   && actions[nactions-1][1]==something[1] // don't merge change with delete
   && actions[nactions-1][2]==something[2] && something[6]!=null )
  { actions[nactions-1][4] = something[4] ; // caption
    actions[nactions-1][6] = something[6] ; // type
  }
  else { actions[nactions++] = something ; donesomething() ; }
}
function donesomething()
{ actions.length = nactions ; 
  blackout(undobtn) ; 
  greyout(redobtn) ; 
  if(actiontype(actions[nactions-1][0])!=0) 
  { if(unsavedchanges.length>=3) unsavedchanges.push(null) ; 
    else unsavedchanges.push(actionname(actions[nactions-1])) ;
  }
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- undo  ---------------------------------- */

function undo()
{ infowindow.close() ;  
  var opts = 'Undo ' + actionname(actions[nactions-1]) ;
  infowindow.open(genclickable('confirmedundo()',opts),getbtnpos(5),'undo') ; 
}
function confirmedundo()
{ var i,ano=nactions-1,action=actions[ano][0],s0=actions[ano][1],s1,caption ;
  var oldcaption,task,ind ; 
  infowindow.close() ;  

  if(action!='revseg'&&action!='interpolate'&&action!='deltimes') 
    s1 = actions[ano][2] ;

  if(action=='bin') 
  { disconnect(s0-1) ; 
    for(i=segments.length;i>s0;i--) 
    { segments[i] = segments[i-1] ; recolour(i) ; } 
    segments[s0] = s1 ;
    for(s1=0;s1<segments[s0].data.length;s1++)
      segments[s0].data[s1].setmap(map,selpoint,selpoint,labelcycle) ; 
    draw(s0) ; 
    connect(s0-1) ; 
    connect(s0) ; 
    if(selected[0]>=s0) selected[0] += 1 ; 
    drawsel(1) ; 
    greyout(dlbtn) ; 
  }
  else if(action=='snip') // undo snip
  { selected = [ s0 , segments[s0].data.length-1 ] ; 
    combine1(s0,s0+1) ; 
    for(i=s0+1;i<segments.length-1;i++) 
    { segments[i] = segments[i+1] ; recolour(i) ; } 
    segments.length -= 1 ; 
    if(segments.length==1) blackout(dlbtn) ; 
    draw(s0) ;
    drawsel(1) ; 
  } 
  else if(action=='editlabel')  // undo create/edit/delete label
    segments[s0].data[s1].setlabel(actions[ano][5],actions[ano][3],
                                   map,selpoint,labelcycle) ;
  else if(action=='edittitle') settitle(s0) ; 
  else if(action=='editdescription') setdesc(s0) ; 
  else if(action=='wpdel')      // ['wpdel',s0,s1,wpdelwork(s0,s1)]
  { insert(s0,s1,1) ; 
    segments[s0].data[s1] = actions[ano][3] ;
    segments[s0].data[s1].setmap(map,selpoint,selpoint,labelcycle) ;
    redrawconnect(s0,s1) ;
    drawsel(1,[s0,s1]) ; 
  }
  else if(action=='move')
  { if(actions[ano][5]) wpdelwork(s0,s1) ; else move(s0,s1,actions[ano][3]) ; }
  else if(action=='recal') calwork(s0,-s1) ; 
  else if(action=='setalt') segments[s0].data[s1].h = actions[ano][3] ;
  else if(action=='combine') uncombine(actions[ano]) ; 
  else if(action=='revseg') revsegwork(s0) ; 
  else if(action=='stars') routeprops.stars = s0 ; 
  else if(action=='deltimes') for(i=0;i<s0.length;i++) 
    segments[s0[i][0]].data[s0[i][1]].t = s0[i][2] ;
  else if(action=='optimise') // [ 'load' , s0 , data.slice() , props ]
  { for(ano--;ano>=0&&actions[ano][0]!='load';ano--) ;
    segments[s0].data = actions[ano][2] ;
    actions[loadno][3].optim.ndel = 0 ; 
    redraw(s0) ;
    drawsel(1,[s0,0]) ; 
  }
  else if(action=='editphoto') 
  { ind = actions[ano][3] ;
    if(actions[ano][5]==null)      // undo delete
      for(i=segments[s0].data[s1].photo.length;i>ind;i--)
        segments[s0].data[s1].photo[i] = segments[s0].data[s1].photo[i-1] ;
    if(ind>=segments[s0].data[s1].photo.length)
      segments[s0].data[s1].addphoto(actions[ano][4],map,selpoint) ;
    else segments[s0].data[s1].setphoto(ind,actions[ano][4],selpoint) ;
  }
  else if(action=='extra') 
    for(selected=[s0,s1],i=actions[ano].length-1;i>=3;i--)
  { task = actions[ano][i]
    segments[task[0]].data.splice(task[1],task[2].length-2) ; 
  }

  nactions -= 1 ; 
  if(nactions<=1) greyout(undobtn) ; 
  blackout(redobtn) ; 
  if(actiontype(actions[nactions][0])!=0&&unsavedchanges.length>0)
    unsavedchanges.length -= 1 ;  ;
  if( action=='optimise' || action=='dltimes' || action=='stars'
   || action=='editdescription') routeinfo() ; 
  else if(action=='editphoto'||action=='editlabel') walkto(s0,s1) ;
}
/* --------------------------------- move ----------------------------------- */

function move(s0,s1,pos)
{ segments[s0].data[s1].setpos(pos) ; redrawconnect(s0,s1) ; drawsel(1) ; }

/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- redo  ---------------------------------- */

function redo()
{ infowindow.close() ;  
  var opts = 'Redo ' + actionname(actions[nactions]) ;
  infowindow.open(genclickable('confirmedredo()',opts),getbtnpos(6),'redo') ; 
}
function confirmedredo()
{ var i,action=actions[nactions][0],s0=actions[nactions][1],s1,caption,a,b,c ;
  var task,ind,photo ; 
  if(action!='bin'&&action!='revseg'&&action!='interpolate'&&action!='deltimes')
    s1 = actions[nactions][2] ;
  infowindow.close() ; 

  if(action=='bin') binwork(s0) ; 
  else if(action=='snip') snipwork(s0,s1) ; 
  else if(action=='editlabel') // redo create/edit/delete label
    segments[s0].data[s1].setlabel(actions[nactions][6],actions[nactions][4],
                                   map,selpoint,labelcyle) ;
  else if(action=='edittitle') settitle(s1) ; 
  else if(action=='editdescription') setdesc(s1)  ; 
  else if(action=='wpdel') wpdelwork(s0,s1) ; 
  else if(action=='move')     // ['move',s0,s1,oldpos,newpos,inserted]
  { if(actions[nactions][5]) insert(s0,s1,1) ; 
    move(s0,s1,actions[nactions][4]) ; 
  }
  else if(action=='recal') calwork(s0,s1) ; 
  else if(action=='setalt') segments[s0].data[s1].h = actions[nactions][4] ;
  else if(action=='combine') recombine(actions[nactions]) ; 
  else if(action=='revseg') revsegwork(s0) ; 
  else if(action=='stars') routeprops.stars = s1 ; 
  else if(action=='deltimes') for(i=0;i<s0.length;i++) 
    segments[s0[i][0]].data[s0[i][1]].t = null ;
  else if(action=='optimise') 
  { result = optimise(segments[s0].data,actions[nactions][2]) ; 
    actions[loadno][3].optim.ndel = segments[s0].data.length - result.length ;
    segments[s0].data = result ; 
    redraw(s0) ;
    drawsel(1,[s0,0]) ; 
    routeinfo() ; 
  }
  else if(action=='editphoto') 
  { ind = actions[nactions][3] ;
    photo = actions[nactions][5] ;
    if(actions[nactions][4]==null) 
      segments[s0].data[s1].addphoto(photo,map,selpoint) ;
    else segments[s0].data[s1].setphoto(ind,photo,selpoint) ; 
  }
  else if(action=='extra') 
    for(selected=[s0,s1],i=3;i<actions[nactions].length;i++)
  { task = actions[nactions][i] ;
    a = segments[task[0]].data.slice(0,task[1]) ;
    b = task[2].slice(1,task[2].length-1) ; 
    c = segments[task[0]].data.slice(task[1]) ;
    segments[task[0]].data = a.concat(b,c) ; 
  }

  nactions += 1 ; 
  if(nactions==actions.length) greyout(redobtn) ; 
  blackout(undobtn) ; 
  if(actiontype(actions[nactions-1][0])!=0) unsavedchanges.push(action) ;
  if(action=='stars'||action=='editdescription') routeinfo() ; 
  else if(action=='editphoto'||action=='editlabel') walkto(s0,s1) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ---------------------------------- dl  ----------------------------------- */

function dl(opt) 
{ var str,i,npix,s0,s1,filename ;
  infowindow.close() ; 
  if(opt==undefined||opt==0) { opt = 0 ; getalts(1) ; }

  // filename
  i = routeprops.title.indexOf(' ') ;
  if(i<=0) filename = routeprops.title ; 
  else filename = routeprops.title.substring(0,i) ;
  if(filename==''||filename==null) filename = 'Untitled' ; 
  filename += '.tcx' ; 

  // check for photos
  for(npix=s0=0;s0<segments.length;s0++) 
    for(s1=0;s1<segments[s0].data.length;s1++) 
      npix += segments[s0].data[s1].photo.length ;

  // photo list
  if(npix>0&&imginfo.status=='ready')
  { if(imginfo.type=='tcx') routeprops.list = imginfo.uri ; // 'tcx' vice 'uri'
    else 
    { routeprops.list = document.URL ;
      if((i=routeprops.list.lastIndexOf('?'))>=0) 
        routeprops.list = routeprops.list.substring(0,i) ; 
      routeprops.list = reluri(routeprops.list,imginfo.uri) ; 
    }
  }

  if(opt) // write index and return 
  { str = writeoverview(segments,routeprops.title,routeprops.list) ;  
    saveAs(new Blob([str],{type: "text/plain;charset=utf-8"}),filename) ;
    return ;
  }

  // record optimisation 
  routeprops.optim.origlen = routeprops.optim.ndel = 0 ; 
  routeprops.optim.parms = null ; 
  for(i=0;i<nactions;i++) if(actions[i][0]=='load')
  { routeprops.optim.ndel += actions[i][3].optim.ndel ; 
    routeprops.optim.origlen += actions[i][3].optim.origlen ; 
    routeprops.optim.parms = actions[i][3].optim.parms ;
  }
  
  str = writetcx(routeprops,segments[0].data) ;  
  if(str==null) return ; 
  unsavedchanges = [] ; 
  saveAs(new Blob([str],{type: "text/plain;charset=utf-8"}),filename) ;
}
/* -------------------------------------------------------------------------- */

• xmlfloat     • isvaliddate     • isvalidnum     • datatype     • function     • addlabel     • propstype     • readtcx     • readgpx     • dist     • angle     • optimise     • writetcx     • addpos     • addalt     • adddist     • writeoverview     • gencolours     • ascify

// www.masterlyinactivity.com/software/routemaster.html
//
/* The MIT License

   Copyright (c) 2016, by Colin Champion <colin.champion@masterlyinactivity.com>

   Permission is hereby granted, free of charge, to any person obtaining
   a copy of this software and associated documentation files (the
   "Software"), to deal in the Software without restriction, including
   without limitation the rights to use, copy, modify, merge, publish,
   distribute, sublicense, and/or sell copies of the Software, and to
   permit persons to whom the Software is furnished to do so, subject to
   the following conditions:

   The above copyright notice and this permission notice shall be
   included in all copies or substantial portions of the Software.

   THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
   EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
   MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
   NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS
   BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN
   ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
   CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
   SOFTWARE.
*/
var icons = 
{ // coursepoint icons
  flagsign:
  { path: "M 0.5 20.5  L 0.5 0.5  12.5 6  0.5 11.5  ",
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(0.5,20.5),
  } ,
  turnleft:
  { path: "M 18.5 20.5  L 16.5 11.5  A 2 2 0 0 0 14.5 9.5  "+
          "L 11.5 10  11.5 13.5  "+
          "6.5 7.5  11.5 1.5  11.5 5  16.5 5.5  A 3.5 3.5 0 0 1 20 9   z",
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(18.5,20.5),
  } ,
  straighton:
  { path: "M 7.5 20.5  L 4.5 6.5  0.5 6.5  7.5 0.5  14.5 6.5  10 6.5  z",
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(7.5,20.5),
  } ,
  turnright:
  { path: "M 3.5 20.5  L 5.5 11.5  A 2 2 0 0 1 7.5 9.5  L 10.5 10  10.5 13.5  "+
          "15.5 7.5  10.5 1.5  10.5 5  5.5 5.5  A 3.5 3.5 0 0 0 2 9   z",
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(3.5,20.5),
  } ,
  shriek:
  { path: "M 8.5 21.5 A 2.5 2.5 0 0 1 8.5 16.5  A 2.5 2.5 0 1 1 8.5 21.5  "+
          "M 8.5 14.5   4.5 5.5  A 4.5 4.5 0 1 1 12.5 5.5 L 8.5 14.5" ,
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(8.5,21.5),
  } ,
  fork:
  { path: "M 0.5 0.5  L 0.5 5.5  2.5 5.5  2.5 0.5  2.5 5.5  4.5 5.5  4.5 0.5" +
          "  4.5 5.5  6.5 5.5   6.5 0.5   6.5 7.5  " +
          "A 2.5 2.5 0 0 1 4.25 9.95   L 5 19.5  "+
          "A 1.5 1.5 0 0 1  2 19.5   L 2.75 9.95 " + 
          "A 2.5 2.5 0 0 1 0.5 7.5   z",
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(3.5,22),
  } ,
  // icon for arrow representing current waypoint
  arrow:
  { path: "M 6 9  0 15  6 0  12 15 z",
    fillColor: 'black',
    fillOpacity: 1,
    strokeColor: 'black',
    strokeWeight: 0,
    anchor: new google.maps.Point(6,6),
    rotation: 0,
    clickable: false 
  } ,
  // icon for concentric circles representing draggable waypoint
  concircle:
  { path: "M 6 0  A 6 6 0 1 0 6 12  A 6 6 0 1 0 6 0 M 6 3  " +
          "A 3 3 0 1 0 6  9   A 3 3 0 1 0 6  3",
    fillColor: 'black',
    fillOpacity: 0,
    strokeColor: 'black',
    strokeWeight: 1,
    strokeOpacity: 1,
    anchor: new google.maps.Point(6,6),
    clickable: false 
  } ,
  // camera icon
  camera:
  { path: "M 0.5 4   A 1.5 1.5 0 0 1 2 2.5   L  5.5 2.5   7 0.5  11 0.5   " + 
          "12.5 2.5   14 2.5   A 1.5 1.5 0 0 1  16 3   L 20 7   16 11 " +
          "A 1.5 1.5 0 0 1 15 11.5   L 2 11.5   A 1.5 1.5 0 0 1 0.5 10  z " + 
          "M 9 4  A 3 3 0 0 1 9 10   A 3 3 0 0 1 9 4 " ,  
    fillColor: '#FCDFFF',
    fillOpacity: 0.7,
    strokeColor: 'purple',
    strokeWeight: 1.5,
    anchor: new google.maps.Point(21,7),
    clickable: false 
  } 
} ;
function xmlfloat(x) { return parseFloat(x.childNodes[0].textContent) ; }

function isvaliddate(d) 
{ if(Object.prototype.toString.call(d)!=="[object Date]") return false ;
  else return !isNaN(d.getTime()) ;
}
function isvalidnum(x) { return !isNaN(parseFloat(x)) && isFinite(x) ; }

/* ------------------------------- data structure --------------------------- */

// I found the following logic quite hard to get right. A (non-null) label
// satisfies the following constraints:
// o. the marker is non-null
// o. the map may be null, and if it is null the title may also be null and the
//    icon may be arbitrary
// o. if the type is null, the map is null
// o. the map is null if and only if the clickhandler is inactive
// the same constraints apply (mutatis mutandis) to the photo, so it follows 
// that the label may have a null map and the photo non-null (and vice versa)
//    we therefore conclude that a label must be in one of 3 states:
// o. type null, map null, handlers inactive, but marker non-null
// o. type non-null, map null, handlers inactive, marker non-null
// o. type non-null, map non-null, handlers active, marker non-null
// the state in which type is non-null and map is null is applied to all 
// labels in a segment being deleted (we preserve the information in the 
// action list but don't want the label to be displayed)

function datatype(pos,h,t)
{ this.pos = pos ; 
  this.h = h ; 
  this.marker = this.photomarker = this.type = this.t = null ;
  if(t!=undefined&&t!=null&&isvaliddate(t)&&t.getTime()>365*24*3600000) 
    this.t = t ; 
  this.photo = [] ;
  this.caption = '' ;  
  this.clickhandler = this.righthandler = this.photohandler = null ; 
}
// member functions
datatype.prototype.geticon = function()
{ if(this.type=='Left')  return icons.turnleft ; 
  else if(this.type=='Straight') return icons.straighton ; 
  else if(this.type=='Right') return icons.turnright ; 
  else if(this.type=='Danger') return icons.shriek ; 
  else if(this.type=='Food') return icons.fork ; 
  else return icons.flagsign ; 
} ;
datatype.prototype.setlabelmap = function(m,seller,cyclist) 
{ if(this.type==null) m = null ; 
  if(m==null&&this.marker==null) return ;
  this.marker.setMap(m) ; 
  if(m==null&&this.clickhandler!=null)
  { google.maps.event.removeListener(this.clickhandler) ;
    google.maps.event.removeListener(this.righthandler) ;
    this.clickhandler = this.righthandler = null ; 
  }
  if(m!=null&&this.clickhandler==null)
  { if(seller!=undefined&&seller!=null)
      this.clickhandler = this.marker.addListener('click',seller) ;
    if(cyclist!=undefined&&cyclist!=null)
      this.righthandler = this.marker.addListener('rightclick',cyclist) ;
  }
} ;
datatype.prototype.setphotomap = function(m,seller) 
{ if(this.photo.length==0) m = null ; 
  if(m==null&&this.photomarker==null) return ;
  this.photomarker.setMap(m) ;
  if(m==null&&this.photohandler!=null) 
  { google.maps.event.removeListener(this.photohandler) ;
    this.photohandler = null ; 
  }
  if(m!=null&&this.photohandler==null&&seller!=undefined&&seller!=null) 
    this.photohandler = this.photomarker.addListener('click',seller) ;
} ;
datatype.prototype.setlabel = function(t,c,m,seller,cyclist) 
{ this.type = t ; 
  this.caption = c ; 
  if(t==null) 
  { if(this.marker!=null) this.setlabelmap(null,null,null) ; return ; } 
  if(this.marker==null) this.marker = new google.maps.Marker
      ({ position:this.pos,map:m,icon:this.geticon(),title:c,zIndex:1 }) ;
  else { this.marker.setIcon(this.geticon()) ; this.marker.setTitle(c) ; }
  this.setlabelmap(m,seller,cyclist) ; 
} ;
datatype.prototype.setphoto = function(ind,p,seller) 
{ var i ;
  if(p==null)
  { for(i=ind;i<this.photo.length-1;i++) this.photo[i] = this.photo[i+1] ; 
    this.photo.length -= 1 ; 
    if(this.photo.length==0&&this.photomarker!=null) 
      this.setphotomap(null,null) ; 
    return ; 
  }
  else { this.photo[ind] = p ; if(ind==0) this.photomarker.setTitle(p) ; }
} ;
datatype.prototype.addphoto = function(p,m,seller) 
{ this.photo.push(p) ; 
  if(this.photomarker==null) this.photomarker = new google.maps.Marker
      ({ position:this.pos,map:m,icon:icons.camera,title:p,zIndex:1 }) ;
  this.setphotomap(m,seller) ; 
} ;
datatype.prototype.setpos = function(p) 
{ this.pos = p ; 
  if(this.type!=null) this.marker.setPosition(p) ; 
  if(this.photo.length>0) this.photomarker.setPosition(p) ; 
} ;
datatype.prototype.setmap = function(m,flagsel,photosel,cyclist) 
{ this.setlabelmap(m,flagsel,cyclist) ; this.setphotomap(m,photosel) ; } ;

datatype.prototype.settype = function(t) 
{ this.type = t ; this.marker.setIcon(this.geticon()) ; } ;

datatype.prototype.labelcycle = function()
{ var oldtype = this.type , type ; 
  if(oldtype=='Generic') type = 'Left' ; 
  else if(oldtype=='Left') type = 'Straight' ; 
  else if(oldtype=='Straight') type = 'Right' ; 
  else if(oldtype=='Right') type = 'Danger' ; 
  else if(oldtype=='Danger') type = 'Food' ; 
  else type = 'Generic' ; 
  this.settype(type) ;
  return [ oldtype , type ] ; 
}
function addlabel(data,pos,type,caption,m,seller,cyclist) 
{ var j,ind,mindist ; 
  for(j=0;j<data.length;j++) if(j==0||dist(pos,data[j].pos)<mindist) 
  { mindist = dist(pos,data[j].pos) ; ind = j ; } 
  data[ind].setlabel(type,caption,m,seller,cyclist) ;
}
function propstype()
{ this.desc = this.title = this.list = this.inputlen = this.source = null ;
  this.stats = this.tracklink = this.overview = this.stars = null ; 
  this.photo = [] ;
  this.optim = { already: 0, ndel: 0, origlen: 0, parms: null }
}
/* -------------------------------------------------------------------------- */

function readtcx(xmldoc,m,flagsel,photosel,cyclist)
{ var nodeno,type,lat,lon,i,j,node,alt,pos,segment,props,title,list,txt ;
  var ind,caption,data,photo,time,valid,anc,xmlnodes,nsegment,propno,names ;
  var track,trackpoint,trackno,course,coursepoint,courseno,courselen,overview ;
  var validalt,fieldnames ; 

  // loop over trackpoints 
  track = xmldoc.getElementsByTagName('Trackpoint') ;
  for(courselen=[],data=[],anc=null,trackno=0;trackno<track.length;trackno++)
  { trackpoint = track[trackno] ;
    if(trackpoint.parentNode.parentNode.nodeName=='Course')
      if(trackpoint.parentNode.parentNode!=anc) 
    { anc = trackpoint.parentNode.parentNode ; 
      courselen.push([trackno,anc]) ; 
    }
    lat = lon = alt = time = null ; 
    photo = [] ;
    for(validalt=valid=1,nodeno=0;nodeno<trackpoint.childNodes.length;nodeno++)
    { node = trackpoint.childNodes[nodeno] ;

      if(node.nodeName=='AltitudeMeters') alt = xmlfloat(node) ; 
      else if(node.nodeName=='Time') // '1970-01-01T03:040:08Z'
        time = new Date(node.childNodes[0].textContent) ; 
      else if(node.nodeName=='Position') for(j=0;j<node.childNodes.length;j++)
      { if(node.childNodes[j].nodeName=='LatitudeDegrees') 
          lat = xmlfloat(node.childNodes[j]) ; 
        else if(node.childNodes[j].nodeName=='LongitudeDegrees') 
          lon = xmlfloat(node.childNodes[j]) ;
      }
      else if(node.nodeName=='Extensions') for(j=0;j<node.childNodes.length;j++)
      { if(node.childNodes[j].nodeName=='Photo') 
          photo = node.childNodes[j].childNodes[0].textContent.split(' ') ;
        else if(node.childNodes[j].nodeName=='ValidTime') valid = 0 ;
        else if(node.childNodes[j].nodeName=='ValidAlt') validalt = 0 ;
      }
    }
    if(lat==null||lon==null) continue ; 
    if(!isvalidnum(alt)) validalt = 0 ; 
    pos = new google.maps.LatLng(lat,lon) ;
    data.push(new datatype(pos,validalt?alt:null,valid?time:null)) ; 
    for(ind=0;ind<photo.length;ind++) 
      data[data.length-1].addphoto(photo[ind],map,photosel) ;
  }
  if(track.length==0) { alert('no trackpoints') ; throw '' ; }
  if(courselen.length==0) courselen.push([0,null]) ; 
  courselen.push([track.length,null]) ; 

  // loop over coursepoints
  course = xmldoc.getElementsByTagName('CoursePoint') ;
  for(courseno=0;courseno<course.length;courseno++)
  { coursepoint = course[courseno] ;
    caption = type = lat = lon = null ;
    for(nodeno=0;nodeno<coursepoint.childNodes.length;nodeno++)
    { node = coursepoint.childNodes[nodeno] ;
      if(node.nodeName=='Name') caption = node.childNodes[0].textContent ; 
      else if(node.nodeName=='PointType') 
        type = node.childNodes[0].textContent ; 
      else if(node.nodeName=='Position') for(j=0;j<node.childNodes.length;j++)
      { if(node.childNodes[j].nodeName=='LatitudeDegrees') 
          lat = xmlfloat(node.childNodes[j]) ;
        else if(node.childNodes[j].nodeName=='LongitudeDegrees') 
          lon = xmlfloat(node.childNodes[j]) ;
      }
    }
    if(lat==null||lon==null||caption==null||type==null) 
    { alert('Badly formatted course point' ) ; throw '' ; }
    pos = new google.maps.LatLng(lat,lon) ;
    addlabel(data,pos,type,caption,m,flagsel,cyclist) ; 
  }

  for(segment=[],i=0;i<courselen.length-1;i++)
    segment.push(data.slice(courselen[i][0],courselen[i+1][0])) ;
  nsegment = segment.length ;

  // props fields
  props = new Array(nsegment) ; 
  for(i=0;i<nsegment;i++) 
  { props[i] = new propstype() ;
    props[i].inputlen = data.length ; 
    if(props[i].optim.origlen==0) props[i].optim.origlen = data.length ;
  }

  // optimised?
  xmlnodes = xmldoc.getElementsByTagName('Optimised') ;
  if(xmlnodes.length)
  { props[0].optim.already = 1 ; 
    props[0].optim.origlen = parseInt(xmlnodes[0].getAttribute('from')) ; 
    props[0].optim.ndel = 
      props[0].optim.origlen - parseInt(xmlnodes[0].getAttribute('to')) ; 
    props[0].optim.parms = 
      { tol: parseFloat(xmlnodes[0].getAttribute('tol')) ,
        maxsep: parseFloat(xmlnodes[0].getAttribute('maxsep')) ,
        wppenalty: parseFloat(xmlnodes[0].getAttribute('wppenalty')) ,
        vweight: parseFloat(xmlnodes[0].getAttribute('vweight')) 
      } ; 
    for(i=1;i<nsegment;i++) props[i].optim = props[0].optim ; 
  }

  // overview fields (different values for each segment) all in one big loop
  names = [ 'Name' ,     'LongTitle' , 'Description' , 'Stats' , 'PhotoList' ,
            'Overview' , 'Index' ,     'TrackLink' , 'Stars' ,       'Photo' ] ;
  fieldnames = [ 'title' ,    'desc' ,     'desc' ,      'stats' , 'list' ,
                 'overview' , 'overview' , 'tracklink' , 'stars' , 'photo' ] ;
  for(title=null,propno=0;propno<names.length;propno++)
  { xmlnodes = xmldoc.getElementsByTagName(names[propno]) ;
    for(i=0;i<xmlnodes.length;i++)
    { node = xmlnodes[i] ;
      if(propno==0) anc = node.parentNode ;
      else anc = node.parentNode.parentNode ;
      if(propno==4) txt = node.getAttribute('src') ;
      else if(propno==5||propno==6||propno==7) txt = node.getAttribute('href') ;
      else txt = node.childNodes[0].textContent ;
      for(j=0;j<nsegment&&anc!=courselen[j][1];j++) ;
      if(j==nsegment)
      { if(propno==0&&title==null)
          if( node.parentNode.nodeName=='Courses' 
           || node.parentNode.nodeName=='Lap' ) title = txt ;  
        continue ; 
      }
      if(names[propno]=='Photo') props[j].photo = txt.match(/\S+/g) ;
      else props[j][fieldnames[propno]] = txt ;
    }
  }

  return { title: title , props: props , segments: segment } ;
}
/* -------------------------------------------------------------------------- */

function readgpx(xmldoc,m,flagsel,cyclist)
{ var xmlcoords,nodeno,type,lat,lon,i,node,alt,pos,caption,data,time ; 
  var props = new propstype() ; 

  // get the route name
  xmlcoords = xmldoc.getElementsByTagName('name') ;
  for(i=0;props.title==null&&i<xmlcoords.length;i++)
    if(xmlcoords[i].parentNode.nodeName!='wpt') 
      props.title = xmlcoords[i].childNodes[0].textContent.substring(0,15) ;

  // get the route description
  xmlcoords = xmldoc.getElementsByTagName('desc') ;
  if(xmlcoords.length>0&&xmlcoords[0].childNodes.length>0) 
    props.desc = xmlcoords[0].childNodes[0].textContent ;

  // loop over the track points to get the coords
  xmlcoords = xmldoc.getElementsByTagName('trkpt') ;
  if(xmlcoords.length==0) xmlcoords = xmldoc.getElementsByTagName('rtept') ;

  for(data=[],i=0;i<xmlcoords.length;i++)
  { lat = parseFloat(xmlcoords[i].getAttribute('lat')) ; 
    lon = parseFloat(xmlcoords[i].getAttribute('lon')) ; 
    pos = new google.maps.LatLng(lat,lon) ;

    for(time=alt=null,nodeno=0;nodeno<xmlcoords[i].childNodes.length;nodeno++)
    { node = xmlcoords[i].childNodes[nodeno] ;
      if(node.nodeName=='ele') alt = parseFloat(node.textContent) ; 
      else if(node.nodeName=='time')
        time = new Date(node.childNodes[0].textContent) ; 
    }
    if(!isvalidnum(alt)) alt = null ; 
    data.push(new datatype(pos,alt,time)); 
  }

  // loop over the course points to get the labels
  xmlcoords = xmldoc.getElementsByTagName('wpt') ;
  for(i=0;i<xmlcoords.length;i++)
  { caption = type = lat = lon = null ;
    lat = parseFloat(xmlcoords[i].getAttribute('lat')) ; 
    lon = parseFloat(xmlcoords[i].getAttribute('lon')) ; 
    for(nodeno=0;nodeno<xmlcoords[i].childNodes.length;nodeno++)
    { node = xmlcoords[i].childNodes[nodeno] ;
      if(node.nodeName=='name') caption = node.childNodes[0].textContent ; 
      else if(node.nodeName=='type') type = node.childNodes[0].textContent ; 
    }
    if(lat==null||lon==null||caption==null) 
    { alert('Badly formatted course point' ) ; throw '' ; }
    if(type==null) type = 'Generic' ; 
    pos = new google.maps.LatLng(lat,lon) ;
    addlabel(data,pos,type,caption,m,flagsel,cyclist) ; 
  }

  props.inputlen = data.length ; 
  return { props: [props] , segments: [data] } ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function dist(x,y)
{ return google.maps.geometry.spherical.computeDistanceBetween(x,y) ; }

function angle(x,y)
{ return google.maps.geometry.spherical.computeHeading(x,y)*Math.PI/180 ; }

/* -------------------------------------------------------------------------- */

function optimise(idata,parms)
{ var stk,nnstk,stk2,clen=idata.length,i,j,k,m,step=new Array(clen-1) ; 
  var opos,oalt,npos,nalt,ndatum,mpos,malt,arccentre,arctol,pathpos ; 
  var legal,theta,omega,x,y,hyp,tdist,maxtheta,mintheta,d,dh,od,odh,odash ; 
  var bearings=new Array(clen),nstk=new Array(clen),pi=Math.PI,tol=parms.tol ;
  var backptr = new Array(clen) ; 

  stk = [ { err:0 , pathpos:1 , prev:-1 } ] ;
  for(i=0;i<clen-1;i++) step[i] = dist(idata[i].pos,idata[i+1].pos) ; 

  // this is a forwards dynamic program. in stk we have a list of hypotheses
  // each of which advances a different number of points through the data, 
  // sorted increasing on how far they've advanced. at each step we take the 
  // first item from the stack and try extending to each legal successor point.
  //    note that a hypothesis whose pathpos is k is one whose last point is
  // idata[k-1].
  while(stk[0].pathpos<clen)
  { pathpos = stk[0].pathpos ;
    backptr[pathpos-1] = stk[0].prev ;
    opos = idata[pathpos-1].pos ;
    oalt = idata[pathpos-1].h ; 
    // try extending to pathpos+i
    for(arctol=null,nnstk=i=0;i<clen-pathpos;i++)
    { ndatum = idata[pathpos+i] ; 
      npos = ndatum.pos ; 
      nalt = ndatum.h ; 
      if(i==0) hyp = step[pathpos-1] ;
      else if((hyp=dist(opos,npos))>parms.maxsep) break ; 
      omega = angle(opos,npos) ; 
      // find the min and max legal bearing
      if(hyp>tol) 
      { theta = Math.asin(tol/hyp) ; 
        if(arctol==null) { arccentre = omega ; arctol = theta ; } 
        else
        { for(odash=omega-arccentre;odash>pi;odash-=2*pi) ; 
          while(odash<-pi) odash += 2*pi ;
          maxtheta = Math.min(arctol,odash+theta) ; 
          mintheta = Math.max(-arctol,odash-theta) ; 
          if(maxtheta<mintheta) break ; 
          arccentre += (maxtheta+mintheta) /2 ; 
          arctol     = (maxtheta-mintheta) /2 ;
        }
      } 
      /* -------------------------------------------------------------------- */
      /*page*/
      /* -------------------------------------------------------------------- */

      bearings[i] = { hyp:hyp , omega:omega } ; 
      // see whether this breaches the max error on any intermediate point
      for(legal=1,od=odh=tdist=m=0;m<i;m++,od=d,odh=dh)
      { mpos = idata[pathpos+m].pos ;
        malt = idata[pathpos+m].h ; 
        x = bearings[m].hyp ; 
        theta = bearings[m].omega ; 
        d = x * Math.sin(theta-omega) ; 
        dh = 0 ;
        if(d*d<tol*tol&&oalt!=null&&nalt!=null&&malt!=null)  
        { y = hyp - x*Math.cos(theta-omega) ;
          y = Math.sqrt(d*d+y*y) ; 
          dh = parms.vweight * ( malt - (oalt*y+nalt*x)/(x+y) ) ; 
        }
        if(d*d+dh*dh>tol*tol) { legal = 0 ; break ; } 
        tdist += step[pathpos-1+m] * ( d*d+d*od+od*od + dh*dh+odh*dh+odh*odh ) ;
      }
      // if we emerge with 'legal' non-zero then we may advance to pathpos+i 
      // and tdist is the sum of squared errors
      if(legal) nstk[nnstk++] = 
      ( { err:      stk[0].err + pi*tdist/3 + parms.wppenalty , 
          pathpos:  stk[0].pathpos+i+1 ,
          prev:     pathpos-1 
        } ) ; 
      if(ndatum.type!=null||ndatum.photo.length>0) break ; 
    }  // end loop over i 

    // now we have in nstk the possible extensions of stk[0] in increasing
    // order of end point, so we merge with stk[0..stk.length-1]
    for(stk2=new Array(stk.length+nnstk),i=1,k=j=0;i<stk.length||j<nnstk;)
      if(i==stk.length) stk2[k++] = nstk[j++] ; 
      else if(j==nnstk||stk[i].pathpos<nstk[j].pathpos) stk2[k++] = stk[i++] ; 
      else if(stk[i].pathpos>nstk[j].pathpos) stk2[k++] = nstk[j++] ; 
      else if(stk[i].err<nstk[j].err) { stk2[k++] = stk[i++] ; j += 1 ; } 
      else { stk2[k++] = nstk[j++] ; i += 1 ; } 
    stk = stk2.slice(0,k) ; 
  }

  // thread backwards through the pointers
  for(m=1,i=stk[0].prev;i>=0;i=backptr[i],m++) ;
  nstk = new Array(m) ; 
  for(nstk[m-1]=idata[clen-1],j=m-2,i=stk[0].prev;i>=0;i=backptr[i],j--) 
    nstk[j] = idata[i] ;
  return nstk ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------------- writetcx  -------------------------------- */

function writetcx(props,idata) 
{ var i,j,k,xmldoc,course,lap,datum,filename,track,time,routelen,nnull,sum ; 
  var trackpoint,coursepoint,str,flag,clen=idata.length,tlast,di,dk,pointdist ;
  var origlen,ndel,ano,photo,thisuri,maxsep,sep,tdist,ttime,time,otime,x,y ; 
  var distance = new Array(clen) , msecs = new Array(clen) ;
  var valid = new Array(clen) , alt = new Array(clen) ;

  for(tlast=null,nnull=maxsep=tdist=ttime=i=flag=0;i<clen;otime=time,i++) 
  { if((alt[i]=idata[i].h)==null) nnull += 1 ; 
    time = idata[i].t ;
    if(time!=null) { msecs[i] = time = time.getTime() ; valid[i] = 1 ; } 
    if(tlast!=null&&time!=null&&time<tlast) flag = 1 ; // out of order
    if(time!=null) tlast = time ;
    if(i) 
    { sep = dist(idata[i-1].pos,idata[i].pos) ;
      distance[i] = distance[i-1] + sep ; 
      if(sep>maxsep) maxsep = sep ; 
      if(time!=null&&otime!=null) { tdist += sep ; ttime += time - otime ; }
    }
    else distance[i] = 0 ; 
  }
  routelen = distance[clen-1] ;

  if(maxsep>100&&!confirm('Some gaps between waypoints are >100m.\n'+
    'This will cause problems if used for navigation in a Garmin.\n'+
    'You can hit [OK] and I will proceed anyway, or\n'+
    'you can hit [Cancel] and interpolate extra points\n'+
    '(recommended \u2013 go to Route Info under the cogwheel).')) return null ; 

  // decide what to do if some points have no altitudes
  if(nnull==clen) { alert('no points have altitudes') ; return null ; }
  if(nnull>0&&!confirm(nnull+' waypoints have no associated altitudes.\n' +
  'You can hit [OK] and I will interpolate altitudes (not guaranteed),\n'+
  'or you can hit [Cancel] and try again later when the altitudes may be '+
  'available.')) return null ; 

  if(nnull) for(pointdist=new Array(clen),i=0;i<clen;i=j)
  { for(;i<clen&&idata[i].h!=null;i++) ;          // advance to null
    if(i==clen) break ; 
    for(j=i+1;j<clen&&idata[j]==null;j++) ;       // advance to non-null
    if(i==0) { for(y=idata[j].h;i<j;i++) alt[i] = y ; continue ; } 
    if(j==clen) { for(x=idata[i-1].h;i<j;i++) alt[i] = x ; continue ; } 
    for(sum=k=0;k<=j-i;k++) 
      sum = pointdist[k] = sum + dist(idata[i+k-1].pos,idata[i+k].pos) ; 
    for(x=idata[i-1].h,y=idata[j].h,k=0;k<j-i;k++) 
      alt[i+k] = ( x*(sum-pointdist[k]) + y*pointdist[k] ) / sum ; 
  }

  // fill in missing times
  if(tdist==0||flag!=0) for(i=0;i<clen;i++) msecs[i] = distance[i] * 333 ;
  else for(i=0;i<clen;i=k)
  { for(;i<clen&&idata[i].t!=null;i++) ;      // advance to null
    if(i==clen) break ;
    for(k=i+1;k<clen&&idata[k].t==null;k++) ; // advance to non-null
    for(j=i;j<k;j++) valid[i] = 0 ;
    if(i==0) for(time=msecs[k],j=i;j<k;j++)
      msecs[j] = time - (distance[k]-distance[j])*ttime/tdist ;
    else if(k==clen) for(time=msecs[i-1],j=i;j<clen;j++)
      msecs[j] = time + (distance[j]-distance[i-1])*ttime/tdist ;
    else for(j=i,di=distance[i-1],dk=distance[k];j<k;j++) 
      msecs[j] = ( msecs[i-1]*(dk-distance[j]) + msecs[k]*(distance[j]-di) ) 
                        / (dk-di) ;
  }

  str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
        '<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/' +
        'TrainingCenterDatabase/v2"\n' +
        '          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n' +
        '          xsi:schemaLocation="http://www.garmin.com/' +
        'xmlschemas/TrainingCenterDatabase/v2 ' + 
        'http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">\n' +
        '<!-- http://www.masterlyinactivity.com/software/routemaster.html -->' ;
  str += '\n  <Folders><Courses><CourseFolder Name="Courses">\n' ;
  str += '        <CourseNameRef><Id>'+props.title+'</Id></CourseNameRef>\n' ;
  str += '  </CourseFolder></Courses></Folders>\n<Courses><Course>\n' ; 
  str += '  <Name>'+props.title+'</Name>\n  <Lap>\n' + adddist(routelen) ; 
  time = (msecs[clen-1]-msecs[0]) / 1000 ;
  str += '    <TotalTimeSeconds>' + time.toFixed(0) + '</TotalTimeSeconds>\n' ;
  str += addpos('Begin',idata[0].pos) ; 
  str += addpos('End',idata[clen-1].pos) ; 
  str += '    <Intensity>Active</Intensity>\n' + '  </Lap>\n  <Track>\n' ; 

  // loop over trackpoints
  for(i=0;i<idata.length;i++) 
  { str += '  <Trackpoint>\n' + addpos('',idata[i].pos) ; 
    str += adddist(distance[i]) + addalt(idata[i].h==null?alt[i]:idata[i].h) ;
    str += '    <Time>' + new Date(msecs[i]).toISOString() + '</Time>\n' ;
    if(idata[i].photo.length>0)
    { str += '    <Extensions><Photo>' ;
      for(k=0;k<idata[i].photo.length;k++)
      { if(k) str += ' ' ; str += idata[i].photo[k] ; }
      str += '</Photo></Extensions>\n' ; 
    }
    if(valid[i]==0||idata[i].h==null) 
    { str += '    <Extensions>' ; 
      if(valid[i]==0) str += '<ValidTime>False</ValidTime>' ;
      if((valid[i]==0&&idata[i].h==null)) str += '\n                ' ;
      if(idata[i].h==null) str += '<ValidAlt>False</ValidAlt>' ;
      str += '</Extensions>\n' ; 
    }
    str += '    <SensorState>Absent</SensorState>\n  </Trackpoint>\n' ; 
  }
  str += '  </Track>\n\n' ;

  if( props.optim.ndel>0 || props.list!=null || props.stars!=null
   || props.desc!=null   || props.overview != null) 
  { str += '  <Extensions>\n' ;
    if(props.optim.ndel) 
    { str += '    <Optimised from="' + props.optim.origlen +
                            '" to="' + (props.optim.origlen-props.optim.ndel) ;
      if(props.optim.parms!=null) 
        str += '" tol="' + props.optim.parms.tol.toFixed(0) +
                '" maxsep="' + props.optim.parms.maxsep.toFixed(0) +
                '" wppenalty="' + props.optim.parms.wppenalty.toFixed(0) +
                '" vweight="' + props.optim.parms.vweight.toFixed(1) ;
      str += '"/>\n'
    }
    if(props.list!=null) str += '    <PhotoList src="'+props.list+'"/>\n' ; 
    if(props.stars!=null) str += '    <Stars>'+props.stars+'</Stars>\n' ; 
    if(props.desc!=null) 
      str += '    <Description>'+props.desc+'</Description>\n' ; 
    if(props.overview!=null) 
      str += '    <Index href=">' + props.overview + '"/>\n' ; 
    str += '  </Extensions>\n\n' ;
  }
  
  // finally loop over coursepoints
  for(i=0;i<idata.length;i++) if(idata[i].type!=null)
  { datum = idata[i] ;
    str += '  <CoursePoint><Name>'+datum.marker.title+'</Name>\n' ; 
    str += '    <PointType>'+datum.type+'</PointType>\n' ; 
    str += addpos('',idata[i].pos) + addalt(idata[i].h) ;
    time = new Date(msecs[i]) ; 
    str += '    <Time>' + time.toISOString() + '</Time>\n  </CoursePoint>\n' ;
  }
  return str + '</Course></Courses></TrainingCenterDatabase>\n' ; 
}
/* -------------------------------------------------------------------------- */

function addpos(tag,pos)
{ var str = '    <'+tag+'Position>\n      <LatitudeDegrees>' ; 
  str += pos.lat().toFixed(5)+'</LatitudeDegrees>\n      <LongitudeDegrees>' ;
  str += pos.lng().toFixed(5)+'</LongitudeDegrees>\n    </'+tag+'Position>\n' ;
  return str ;
}
function addalt(x)
{ return '    <AltitudeMeters>' + x.toFixed(0) + '</AltitudeMeters>\n' ; }
function adddist(x)
{ return '    <DistanceMeters>' + x.toFixed(0) + '</DistanceMeters>\n' ; }

/* ----------------------------- writeoverview  ----------------------------- */

function writeoverview(segments,title,list) 
{ var i,j,h,oh,maxalt,minalt,routelen,up,down,ndata,photo,segno,idata,title ;
  var uri ; 
  var str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
        '<!-- http://www.masterlyinactivity.com/software/routemaster.html -->' +
        '\n<!-- **** routemaster index file **** not for navigation ****' +
        ' -->\n<TrainingCenterDatabase><Courses>\n' ;

  if(title!='Untitled Route'&&title!=null) str += '<Name>'+title+'</Name>\n' ;
  str += '\n' ; 

  for(segno=0;segno<segments.length;segno++)
  { idata = segments[segno].data ;
    for(oh=maxalt=minalt=null,routelen=up=down=i=0;i<idata.length;oh=h,i++)
    { if(i) routelen += dist(idata[i-1].pos,idata[i].pos) ;
      if((h=idata[i].h)==null) continue ; 
      if(maxalt==null||h>maxalt) maxalt = h ; 
      if(minalt==null||h<minalt) minalt = h ; 
      if(h!=null&&oh!=null) { if(h>oh) up += h-oh ; else down += oh-h ; } 
    }

    for(photo=[],ndata=new Array(idata.length),i=0;i<idata.length;i++)
    { ndata[i] = new datatype(idata[i].pos,0) ;
      for(j=0;j<idata[i].photo.length;j++) photo.push(idata[i].photo[j]) ; 
    }
    idata = optimise(ndata,{tol:500,maxsep:100000,wppenalty:10000,vweight:0}) ;

    str += '<Course>\n' ;
    if(segments[segno].props.title!=null) 
      str += '  <Name>' + segments[segno].props.title + '</Name>\n' ;
    str += '  <Track>\n' ; 

    // loop over trackpoints
    for(i=0;i<idata.length;i++) 
      str += '    <Trackpoint><Position>\n      <LatitudeDegrees>' +
             idata[i].pos.lat().toFixed(4)+'</LatitudeDegrees>\n      ' +
             '<LongitudeDegrees>' + idata[i].pos.lng().toFixed(4) +
             '</LongitudeDegrees>\n    </Position></Trackpoint>\n' ; 
    str += '  </Track>\n\n  <Extensions>\n' ;

    title = segments[segno].props.stars ;
    if(title!=null) str += '    <Stars>' + title + '</Stars>\n' ; 

    title = segments[segno].props.desc ;
    if(title!=null) str += '    <Description>' + title + '</Description>\n' ; 

    uri = document.URL ;  
    if((title=segments[segno].props.source)!=null) 
    { if(title[1]=='uri') uri = reluri(uri,'?'+title[0]) ; 
      else uri = reluri(uri,'?$FILE$/'+title[0]) ; 
    }
    str += '    <Stats>Distance ' + (routelen/1000).toFixed(1) + 'km;' +
           ' altitude ' + minalt.toFixed(0) + '-' + maxalt.toFixed(0) + 
           'm; \u2191' + up.toFixed(0) + 'm \u2193' + down.toFixed(0) + 
           'm</Stats>\n    <TrackLink href="' + uri + '"/>\n' ; 

    if(list!=null) str += '    <PhotoList src="' + list + '"/>\n' ; 

    if(photo.length)
    { str += '    <Photo>' ;
      for(i=0;i<photo.length;i++) 
      { if(i>0&&i%6==0) str += '\n           ' ; else if(i) str += ' ' ; 
        str += photo[i] ; 
      }
      if(photo.length%6==0) str += '\n           ' ; 
      str += '</Photo>\n' ;
    }
    str += '  </Extensions>\n</Course>\n\n' ;
  }

  return str + '</Courses></TrainingCenterDatabase>\n' ; 
}
/* -------------------------------------------------------------------------- */

function gencolours(n)
{ var colours=new Array(n) ;
  var ind,density,k,a,na,i,j,m,r,g,b ;

  for(ind=0,density=1;ind<n;density*=2)
  { if(density==1) k = 3 ; else k = Math.floor(0.5+0.75*density*(1+density/2)) ;
    a = new Array(k) ;
    if(density==1) { a = [ [0,0] , [0,1] , [1,0] ] ; na = 3 ; }
    else for(na=i=0;i<=density;i++) 
    { if((i&1)==0) for(j=1;i+j<=density;j+=2) a[na++] = [ i , j ] ;
      else for(j=density-i;j>=0;j--) a[na++] = [ i , j ] ;
    }
    if(na!=k) alert('logic error') ; 
    for(k=0;(1<<k)<na;k++) ;
    for(i=0;i<(1<<k)&&ind<n;i++)
    { for(m=j=0;j<k;j++) m |= ((i>>j)&1) << (k-1-j) ;
      if(m>=na) continue ;
      r = ( density - a[m][0] - a[m][1] ) * (255/density) ;
      g = a[m][1] * (180/density) ;
      b = a[m][0] * (300/density) ;
      r = ("00"+Math.floor(0.5+r).toString(16)).substr(-2) ;
      g = ("00"+Math.floor(0.5+g).toString(16)).substr(-2) ;
      b = ("00"+Math.floor(0.5+b>255?255:b).toString(16)).substr(-2) ;
      colours[ind++] = '#' + r + g + b ; 
    }      
  }
  return colours ;
}
/* -------------------------------------------------------------------------- */

function ascify(s)
{ var a = 'àáâäæãåā' , see = 'çćč' , e = 'èéêëēėę' , eye = 'îïíīįì' , l = 'ł' ;
  var n = 'ñń' , o = 'ôöòóœøōõ' , ess = 'ßśš' , u = 'ûüùúū' ;
  var sdash,i,c,C,newc ; 
  for(sdash='',i=0;i<s.length;i++)
  { C = s.charAt(i) ; 
    if(C=='‘'||C=='’'||C=='“'||C=='”'||C=='"') { sdash += "'" ; continue ; }
    if(C=='–') { sdash += '-' ; continue ; }
    if(C.charCodeAt(0)<128) { sdash += C ; continue ; }
    c = C.toLowerCase() ; 
    if(a.indexOf(c)>=0) newc = 'a' ; 
    else if(see.indexOf(c)>=0) newc = 'c' ; 
    else if(e.indexOf(c)>=0) newc = 'e' ; 
    else if(eye.indexOf(c)>=0) newc = 'i' ; 
    else if(l.indexOf(c)>=0) newc = 'l' ; 
    else if(n.indexOf(c)>=0) newc = 'n' ; 
    else if(o.indexOf(c)>=0) newc = 'o' ; 
    else if(ess.indexOf(c)>=0) newc = 's' ; 
    else if(u.indexOf(c)>=0) newc = 'u' ; 
    else newc = '*' ;
    if(c==C) sdash += newc ; else sdash += newc.toUpperCase() ; 
  }
  return sdash ;
}

• reluri     • underline     • textdiv     • seginfodiv     • highdiv     • scrolltype     • function     • scroller     • greybtn     • blackbtn     • buttonimg     • buttoncell     • textcell     • appendrow     • genlink     • genspan     • genclickable     • blurbdiv     • northernmost     • helpdiv     • cogwheelmenu     • walktodiv     • wpinfodiv     • titlediv     • routediv     • ldivadd     • starsline     • createfunc     • phdiv     • profiletype     • procoords     • drawpro     • drawxcur     • actionname     • getalts

/* ---------------------------- relative uri  ------------------------------- */

function reluri(u1,u2) 
{ var last = u1.lastIndexOf('/') ; 
  if(last<0) return u2 ; 
  u1 = u1.substring(0,last) ; 

  while(u2.substring(0,3)=='../')
  { last = u1.lastIndexOf('/') ; 
    if(last<0) return u2 ; 
    u2 = u2.substring(3) ; 
    u1 = u1.substring(0,last) ; 
  }
  return u1 + '/' + u2 ; 
}
/* -------------------------------------------------------------------------- */

function underline(d) 
{ d.setAttribute('style',
      'margin-bottom:2px;border-bottom:solid 1px silver;padding-bottom:2px') ; 
  return d ; 
}

function textdiv(title,body,lim)
{ var div=document.createElement('div'),b,nobr,flag=0 ;
  if(lim==undefined||lim==0) lim = null ; 
  else if(lim<0) { flag = 1 ; lim = -lim ; } 

  if(title!=null) 
  { b = document.createElement('b') ;
    b.appendChild(document.createTextNode(title+': ')) ;
  }

  if(lim==null||body.length<lim)
  { nobr = document.createElement('nobr') ;
    if(title!=null) nobr.appendChild(b) ;
    nobr.appendChild(document.createTextNode(body)) ;
    div.appendChild(nobr) ;
  }
  else 
  { if(title!=null) div.appendChild(b) ; 
    div.appendChild(document.createTextNode(body)) ; 
    if(flag==0) underline(div) ;  
  }
  return div ; 
}
/* -------------------------------------------------------------------------- */

function seginfodiv(segments,segno)
{ var div=document.createElement('div'),props=segments[segno].props,prose,lim ; 
  div.appendChild(textdiv(null,'Segment ' + segno + ' of ' +
    segments.length + ' (' + segments[segno].data.length + ' points)')) ;
  if(props.title!=null) div.appendChild(textdiv('Title',props.title)) ;
  if(props.source!=null) div.appendChild(textdiv('Source',props.source[0])) ;
  if(props.stats==null) lim = -50 ; else lim = 50 ; 
  if(props.desc!=null) div.appendChild(textdiv('Description',props.desc,lim)) ;
  if(props.stats!=null) div.appendChild(textdiv('Stats',props.stats)) ;
  return div ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function highdiv(props,list,sizes,sizeno,names) 
{ var div,scroll,p,a,d,dwid,nfetched=2 ;
  var items,i,ind,maxh,minw,sum,scroll,image=[null,null,null] ;
  for(items=[],i=0;i<names.length;i++) if((ind=findimage(list,names[i]))!=null)
  { scroll =  { ind:     ind,
                shape:   imgshape(list[ind],sizes,sizeno),
                top:     0 } ;
    items.push(scroll) ;
  }
  div = document.createElement('div') ;
  div.setAttribute('style','font-family:helvetica') ; 

  if(items.length>0)  
  { minw = items[0].shape[0] ;
    if(items.length>1) minw += items[items.length-1].shape[0] ;
    for(i=0;i<2&&i<items.length;i++) image[i] = new scrolltype(i) ; 

    for(maxh=i=0;i<items.length;i++)
    { if(items[i].shape[1]>maxh) maxh = items[i].shape[1] ;
      if(i)
      { sum = items[i].shape[0] + items[i-1].shape[0] ;
        if(sum<minw) minw = sum ;
      }
    }
    for(i=0;i<items.length;i++) 
      items[i].top = Math.floor(0.5+(maxh-items[i].shape[1])/2) ;

    scroll = document.createElement('div') ; 
    if(items.length==1) minw -= 4 ; 
    scroll.setAttribute('style','position:absolute;width:'+(minw+4)+'px;'+
                                'height:'+maxh+'px;overflow:hidden') ; 
    for(sum=i=0;i<2&&i<items.length;sum+=items[i].shape[0]+4,i++)
    { image[i].addimage(sum) ; scroll.appendChild(image[i].img) ; }
    div.appendChild(scroll) ;

    p = document.createElement('div') ; 
    p.setAttribute('style','width:'+(minw+4)+'px;'+'height:'+(maxh+4)+'px') ;
    div.appendChild(p) ;
  }

  if(props.title!=null) div.appendChild(textdiv('Title',props.title)) ; 
  if(props.stars!=null) div.appendChild(starsline(props.stars,0)) ;
  if(props.desc !=null) 
  { d = textdiv('Description',props.desc,50) ;
    if(items.length>0&&props.desc.length>=50) 
    { if(minw+4<400) dwid = 400 ; else dwid = minw+4 ;  
      d.setAttribute('style','width:'+dwid+'px') ; 
    }
    div.appendChild(d) ; 
  }
  if(props.stats!=null) div.appendChild(textdiv('Stats',props.stats)) ; 

  if(props.tracklink!=null)
  { nobr = document.createElement('nobr') ;
    a = document.createElement('a') ;
    a.setAttribute('style',
                   'cursor:pointer;color:#0000bd;text-decoration:none') ; 
    a.setAttribute('href',props.tracklink) ; 
    a.setAttribute('target',"_blank") ; 
    a.setAttribute('onclick',"infowindow.close()") ; 
    a.appendChild(document.createTextNode('View track')) ;
    nobr.appendChild(a) ;
    nobr.appendChild(document.createTextNode(' (opens in new tab/window)')) ;
    div.appendChild(nobr) ;
  }
  return { div:div , scroller: items.length<=2?null:setInterval(scroller,30) } ;

  function scrolltype(i)
  { this.img = this.top = this.pos = null ; 
    this.ind = i ; // index into items
    this.wid = items[i].shape[0] ;
    this.addimage = function(pos)
    { var fetch=null,ind=this.ind ; 
      if(ind<items.length-1&&nfetched<=ind)
      { nfetched += 1 ; 
        fetch = function() { genimage(list[items[ind+1].ind],sizes,sizeno) ; }
      }
      this.img = genimage(list[items[ind].ind],sizes,sizeno,fetch) ;
      this.pos = pos ; 
      this.scrollimage() ; 
    }
    this.scrollimage = function()
    { this.img.setAttribute('style',
        'position:absolute;top:'+items[this.ind].top+'px;left:'+this.pos+'px') ;
    }
  }
  function scroller()
  { var i,ind,offset ; 
    for(i=0;i<3;i++) if(image[i]!=null) image[i].pos -= 1 ; 
    if(image[0].pos+image[0].wid<=0)
    { scroll.removeChild(image[0].img) ; 
      for(i=0;i<2;i++) image[i] = image[i+1] ; 
      image[2] = null ; 
    } 
    for(i=0;i<3&&image[i]!=null;i++) image[i].scrollimage() ;
    offset = image[i-1].pos + image[i-1].wid + 4 ;
    if(offset<minw)
    { if(image[i-1].ind==items.length-1) ind = 0 ; 
      else ind = image[i-1].ind + 1 ; 
      image[i] = new scrolltype(ind) ;
      image[i].addimage(offset) ;
      scroll.appendChild(image[i].img) ; 
    }
  }
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function greybtn(uri,name)
{ if(name=='cursor') name = 'arrow' ; else name = 'grey' + name ; 
  return uri + name + '.png' ;
}
function blackbtn(uri,name)
{ if(name=='cursor') name = 'hand' ; else name = 'black' + name ; 
  return uri + name + '.png' ;
}
function buttonimg(gif)
{ var img = document.createElement('img') ;
  img.setAttribute('src',gif) ; 
  img.setAttribute('width','24') ; 
  img.setAttribute('height','24') ; 
  return img ;
}
function buttoncell(gif1,gif2) 
{ var td=document.createElement('td'),nobr=document.createElement('nobr') ;
  td.setAttribute('style','padding-bottom:4px') ; 
  nobr.appendChild(buttonimg(gif1)) ; 
  if(gif2!=null&&gif2!=undefined)
  { nobr.appendChild(document.createTextNode(' ')) ; 
    nobr.appendChild(buttonimg(gif2)) ;
  }
  td.appendChild(nobr) ; 
  return td ;
}
function textcell(p1,p2) 
{ var td=document.createElement('td'),nobr ;
  td.setAttribute('style','padding-bottom:4px') ; 
  nobr = document.createElement('nobr') ;
  nobr.appendChild(document.createTextNode(p1)) ;
  td.appendChild(nobr) ;
  if(p2!=null&&p2!=undefined)
  { td.appendChild(document.createElement('br')) ;
    nobr = document.createElement('nobr')
    nobr.appendChild(document.createTextNode(p2)) ;
    td.appendChild(nobr) ;
  }
  return td ;
}
function appendrow(td,p)
{ var nobr = document.createElement('nobr') ;
  nobr.appendChild(document.createTextNode(p)) ; 
  td.appendChild(nobr) ;
  td.appendChild(document.createElement('br')) ;
}
function genlink(uri,legend,blank)
{ var a = document.createElement('a') ;
  a.setAttribute('style','cursor:pointer;color:#0000bd;text-decoration:none') ; 
  a.setAttribute('href',uri) ; 
  if(blank!=undefined) a.setAttribute('target','_blank') ; 
  a.appendChild(document.createTextNode(legend)) ; 
  return a ;
}
function genspan(legend,bropt,spanstyle)
{ var span = document.createElement(bropt=='hr'?'div':'span') ; 
  span.appendChild(document.createTextNode(legend)) ; 
  if(spanstyle==undefined) spanstyle = '' ; 
  else if(spanstyle!='') spanstyle += ';' ; 
  if(bropt==']br') 
  { span.appendChild(document.createTextNode(']')) ; bropt = 'br' ;} 
  if(bropt=='br') span.appendChild(document.createElement('br')) ; 
  else if(bropt=='hr') spanstyle += 
    'margin-bottom:2px;border-bottom:solid 1px silver;padding-bottom:2px' ;
  if(spanstyle!='') span.setAttribute('style',spanstyle) ; 
  return span ;
}
function genclickable(action,legend,bropt)
{ var span = genspan(legend,bropt,'cursor:pointer;color:#0000bd') ; 
  span.setAttribute('onclick',action) ; 
  return span ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function blurbdiv(uri)
{ var div=document.createElement('div'),img=document.createElement('img'),b,p ;
  img.setAttribute('width',16) ; 
  img.setAttribute('height',16) ; 
  img.setAttribute('src',uri+'bus.gif') ; 
  div.appendChild(img) ;
  b = document.createElement('b') ;
  b.appendChild(document.createTextNode
                ('\u00a0\u00a0Routemaster GPS track editor:')) ;
  div.appendChild(b) ;
  div.appendChild(document.createElement('br')) ;
  p = document.createElement('div') ;
  p.appendChild(document.createTextNode
    ('The main use of routemaster is to load GPS tracks, display them, allow'+
     ' them to be edited in various ways, and to save them back to disc.')) ;
  p.setAttribute('style','padding-top:4px') ;
  div.appendChild(p) ; 
  p = document.createElement('div') ;
  p.appendChild(document.createTextNode
    ('It may also be used to display tracks on a website (see the example '+
     'track in the links below) or to display an index of tracks in an '+
     'area (for which there is also an example). However these uses require '+
     'you to host your own instance of the tool.')) ;
  p.setAttribute('style','text-indent:14px;border-bottom:solid 1px silver;'+
                         'padding-bottom:2px') ;
  div.appendChild(p) ; 
  return div ; 
}
/* -------------------------------------------------------------------------- */

function northernmost(data)
{ var i,maxlat,maxi ; 
  for(i=0;i<data.length;i++) if(i==0||data[i].pos.lat()>maxlat)
  { maxi = i ; maxlat = data[i].pos.lat() ; }
  return data[maxi].pos ;
}
/* -------------------------------------------------------------------------- */

function helpdiv(uri,noblank)
{ var div=document.createElement('div'),d,t,tr,td,a ; 
  if(noblank==undefined) noblank = 0 ; 
  t = document.createElement('table') ;
  t.setAttribute('cellpadding',0) ; 
  t.setAttribute('cellspacing',0) ; 
  t.setAttribute('style','font-size:100%') ; 

  tr = document.createElement('tr') ;
  tr.appendChild(buttoncell(greybtn(uri,'cursor'),blackbtn(uri,'cursor'))) ;

  td = document.createElement('td') ;
  td.setAttribute('rowspan',100) ; 
  td.appendChild(document.createTextNode('\u00a0\u00a0\u00a0')) ; // &nbsp;
  tr.appendChild(td) ; 

  tr.appendChild(textcell
    ('toggle between using the mouse to select waypoints and to drag the map',
     '(the space bar has the same function)')) ;
  t.appendChild(tr) ; 

  tr = document.createElement('tr') ;
  tr.appendChild(buttoncell(blackbtn(uri,'settings'),blackbtn(uri,'dl'))) ; 
  tr.appendChild(textcell('access to help menu and to various tools and ' + 
                          'functions /','download route as .tcx')) ; 
  t.appendChild(tr) ; 

  tr = document.createElement('tr') ;
  tr.appendChild(buttoncell(blackbtn(uri,'scissors'),blackbtn(uri,'bin'))) ; 
  tr.appendChild(textcell
    ('split the current segment at the selected point /',
     'delete the currrent segment (or use the [shift delete] '+
     'or [shift backspace] key)')) ;
  t.appendChild(tr) ; 

  tr = document.createElement('tr') ;
  tr.appendChild(buttoncell(blackbtn(uri,'pen'))) ; 
  tr.appendChild(textcell
    ('add a labelled coursepoint at the current position (1-10chars)',
     'click on flag to edit; right-click to change symbol; '+
     'delete label to delete')) ;
  t.appendChild(tr) ; 

  /* ------------------------------------------------------------------------ */
  /*page*/
  /* ------------------------------------------------------------------------ */

  tr = document.createElement('tr') ;
  td = document.createElement('td') ;
  td.setAttribute('valign','top') ; 
  td.appendChild(document.createTextNode('Keyboard: ')) ; 
  tr.appendChild(td) ; 

  td = document.createElement('td') ;
  appendrow(td,
            '\u2190/\u2192 move the current waypoint forwards or backwards;') ;
  appendrow(td,
       '[shift \u2190]/[shift \u2192] move it to the previous/next segment;') ;
  appendrow(td,'\u2193 centres the map on the current waypoint;') ;
  appendrow(td,'[return] makes the current waypoint draggable;') ;
  appendrow(td,'[tab] inserts a draggable waypoint;') ;
  appendrow(td,'[space] = toggle cursor mode;') ;
  appendrow(td,'[del], [backspace] = delete waypoint;') ;
  appendrow(td,
       '[shift del], [shift backspace] = delete segment (=bin button).') ;
  tr.appendChild(td) ; 
  t.appendChild(tr) ; 

  tr = document.createElement('tr') ;
  td = document.createElement('td') ;
  td.setAttribute('valign','top') ; 
  td.appendChild(document.createTextNode('Mouse: ')) ; 
  tr.appendChild(td) ; 

  td = document.createElement('td') ;
  appendrow(td,'when the cursor is in selection mode:') ;
  appendrow(td,
         '[shift click] extends the current segment by the cursor position.') ;
  tr.appendChild(td) ; 
  t.appendChild(tr) ; 

  d = document.createElement('div') ;
  d.appendChild(t) ; 
  div.appendChild(underline(d)) ; 

  /* ------------------------------------------------------------------------ */

  d = document.createElement('div')

  if(noblank)
  { a = genlink('http://www.masterlyinactivity.com/routemaster/?routes/'+
                'Caibros.tcx','Example track to experiment with') ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
    a = genlink('http://www.masterlyinactivity.com/routemaster/?routes/'+
                'capeverde.tcx','Example of a route index') ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
  }

  a = genlink('http://www.masterlyinactivity.com/software/routemaster.html',
              'Technical documentation and source code') ; 
  if(noblank==0) a.setAttribute('target','_blank') ;
  d.appendChild(a) ; 
  if(noblank==0) 
    d.appendChild(document.createTextNode(' (opens in new tab/window)')) ; 

  div.appendChild(d) ; 
  return div ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*            THE MAIN POPUP MENU AND THE FUNCTIONS IT GOVERNS                */
/* -------------------------------------------------------------------------- */

function cogwheelmenu(dragopt,prof,ov)
{ var d = document.createElement('div') ;

  // dragging
  if(dragopt) 
  { d.appendChild(genspan('Hit [return] when you\'ve finished dragging','br')) ;
    return d ;
  }

  // route options
  d.appendChild(genclickable('routeinfo()','Route info','br')) ; 
  if(prof==0) 
    d.appendChild(genclickable('drawprofile()','Show altitude profile','br')) ;
  else d.appendChild(genclickable('unprofile()','Hide altitude profile','br')) ;
  d.appendChild(genclickable('addload(1)','Load new route','br')) ; 
  if(ov!=null)
  { d.appendChild(genlink(ov,'View route index','blank')) ;
    d.appendChild(genspan(' (opens in new tab/window)','br')) ; 
  }
  d.appendChild(genclickable('dl(1)','Download track as route index','hr')) ; 

  // segment options
  d.appendChild(genclickable('seginfo()','Segment info','br')) ; 
  if(unambig()) d.appendChild(genclickable('revseg()','Reverse segment','br')) ;
  else d.appendChild(genspan('Reverse segment','br','color:silver')) ;
  d.appendChild
    (genclickable('manualcal()','Calibrate segment altitudes','br')) ; 
  d.appendChild(genclickable('addload(0)','Load route as a new segment','hr')) ;

  // waypoint options
  d.appendChild(genclickable('wpinfo()','Waypoint info','br')) ; 
  if(segments[selected[0]].data.length>1) 
    d.appendChild(genclickable('wpdel()','Delete waypoint','br')) ; 
  else d.appendChild(genspan('Delete waypoint','br','color:silver')) ;
  d.appendChild(genclickable('draggit(0)','Make waypoint draggable','br')) ; 
  d.appendChild
    (genclickable('inswp(1)','Insert draggable waypoint ahead','br')) ; 
  d.appendChild
    (genclickable('inswp(-1)','Insert draggable waypoint behind','hr')) ; 

  // tool options
  if(querycanfullscreen())
  { if(queryfullscreen()==0) d.appendChild
                (genclickable('enterFullscreen()','Enter full screen','br')) ; 
    else d.appendChild
                (genclickable('exitFullscreen()','Leave full screen','br')) ; 
  } 
  d.appendChild(genclickable('help()','Help','br')) ; 
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function walktodiv(datum) 
{ var s,d=document.createElement('div'),dd,ind,k,slashind,imgname ; 

  if(datum.type!=null)
  { dd = document.createElement('div') ;
    s = datum.type + ': ' + datum.marker.title + ' [' ;
    dd.appendChild(document.createTextNode(s)) ;
    dd.appendChild(genclickable('labelprompt()','Edit',']br')) ;
    if(datum.photo.length>0) underline(dd) ; 
    d.appendChild(dd) ; 
  }

  for(ind=0;ind<datum.photo.length;ind++)
  { dd = document.createElement('div') ;
    if(imginfo.status!='ready') k = -1 ; 
    else k=findimg(datum.photo[ind])
    if(k>=0) 
    { s = genimage(imginfo.list[k],imginfo.sizes,imginfo.thumbind) ;
      dd.appendChild(s) ; 
      dd.appendChild(document.createElement('br')) ;
    }
    else 
    { s = 'Photo: ' + datum.photo[ind] + ' (' ;
      if(imginfo.status=='null') s += 'no list provided) ' ;
      else if(imginfo.status=='ready') 
      { imgname = imginfo.uri ;
        slashind = imgname.lastIndexOf('/') ;
        if(slashind>=0) imgname = imgname.substring(slashind+1) ;
        s += 'not present in ' + imgname + ') ' ; 
      }
      else if(imginfo.status=='waiting') s += imgname + ' is not available) ' ;
      else s += 'imginfo.status = ' + imginfo.status + ') ' ; 
      dd.appendChild(document.createTextNode(s)) ;
    }
    dd.appendChild(document.createTextNode('[')) ;
    dd.appendChild(genclickable('photoedit('+ind+')','Edit')) ;
    dd.appendChild(document.createTextNode(']')) ;
    if(k>=0)
    { dd.appendChild(document.createTextNode(' : [')) ;
      dd.appendChild(genclickable('phinfo('+k+')','Info')) ;
      dd.appendChild(document.createTextNode('] : [')) ;
      dd.appendChild(genclickable('display('+ind+')','Enlarge')) ;
      dd.appendChild(document.createTextNode(']')) ;
    }
    d.appendChild(underline(dd)) ; 
  }
  if(datum.photo.length>0) 
  { dd = document.createElement('div') ;
    dd.appendChild(document.createTextNode('[')) ;
    dd.appendChild(genclickable('photoprompt(null)','Add photo',']br')) ;
    d.appendChild(dd) ; 
  }
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*           WPINFO IS A MENU GIVING ACCESS TO THE SETALT FUNCTION            */
/* -------------------------------------------------------------------------- */

function wpinfodiv() 
{ var s0=selected[0],s1=selected[1],alt,nalt,s,x,lat,lng,grad,gradstr,time ;
  var datum = segments[s0].data[s1] , pos = datum.pos ;
  var s , d = document.createElement('div') ; 

  lat = pos.lat() ; 
  lng = pos.lng() ; 
  if(lat>=0) s = lat.toFixed(5) + '\u00b0 N, ' ; 
  else { lat = -lat ; s = lat.toFixed(5) + '\u00b0 S, ' ; }
  if(lng>=0) s += lng.toFixed(5) + '\u00b0 E' ; 
  else { lng = -lng ; s += lng.toFixed(5) + '\u00b0 W' ; }
  d.appendChild(genspan(s,'br')) ;
    
  x = new LatLon(lat,lng) ; 
  if(lat>49.9&&lat<62&&lng>-12&&lng<2.5&&lat-1.5*lng<75) 
    s = 'OS grid ref: ' + OsGridRef.latLonToOsGrid(x) ;
  else s = 'UTM coords = ' + x.toUtm() ; 
  d.appendChild(genspan(s,'br')) ;

  alt = segments[s0].data[s1].h ;
  if(alt!=null) 
  { d.appendChild(genspan('Altitude: ' + alt.toFixed(0) + 'm [')) ;
    d.appendChild(genclickable('setalt(1)','Edit',']br')) ;
  }
  else d.appendChild(genclickable('setalt(0)','Set altitude','br')) ;

  time = segments[s0].data[s1].t ;
  if(time!=null&&time.getFullYear()>1980) 
  { d.appendChild(genspan('Date: '+time.toDateString(),'br')) ;
    d.appendChild(genspan('Time: '+time.toTimeString(),'br')) ;
  }

  if(datum.type!=null) 
  { d.appendChild(genspan(datum.type + ': ' + datum.marker.title + ' [')) ;
    d.appendChild(genclickable('labelprompt()','Edit',']br')) ;
  }

  if(alt==null||s1==segments[s0].data.length-1) nalt = null ; 
  else 
  { nalt = segments[s0].data[s1+1].h ; 
    if(nalt!=null) x = dist(pos,segments[s0].data[s1+1].pos) ; 
  }
  if(nalt!=null&&Math.abs(nalt-alt)<x) 
  { grad = 100*Math.asin((nalt-alt)/x) ; 
    gradstr = Math.abs(grad).toFixed(0) ; 
    if(gradstr=='0') d.appendChild(genspan('Flat','br')) ;
    else if(grad>0) d.appendChild(genspan('Climb '+gradstr+'%','br')) ;
    else d.appendChild(genspan('Descend '+gradstr+'%','br')) ;
  }
  if(segments.length>1) s = 'Segment '+s0+' p' ; else s = 'P' ;
  s += 'oint ' + s1 ; 
  d.appendChild(genspan(s,null,'font-size:80%')) ;
  return d ;
}
/* -------------------------------------------------------------------------- */

function titlediv(a,b,lim)
{ var bold,d=document.createElement('div') ; 
  if(lim==null||lim==undefined||b.length<=lim) 
  { d.setAttribute('style','white-space:nowrap') ;
    d.appendChild(document.createTextNode(a+': ')) ;
    bold = document.createElement('b') ;
    bold.appendChild(document.createTextNode(b)) ;
    d.appendChild(bold) ;
    d.appendChild(document.createTextNode(' [')) ;
  }
  else d.appendChild(document.createTextNode(a+': '+b+' [')) ;

  d.appendChild(genclickable('retitle("'+a.toLowerCase()+'")','Edit',']br')) ;
  return d ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function routediv(rp,ov) 
{ var s0,s1,s,asc,des,oalt,alt,nlabels,nowpts,i,unsaved,props,spacing,npix ; 
  var maxsep,sep,outoforder,tlast,otime,time,tdist,ttime,ntimes,s,tdiv,ldiv ;
  var nnull , loadno , pr , dd , d = document.createElement('div') ; 

  for(loadno=nactions-1;loadno>=0&&actions[loadno][0]!='load';loadno--) ; 
  if(loadno>=0) pr = actions[loadno][3] ; else pr = null ; 
  tlast = null ;
  tdist = ttime = outoforder = dd = nnull = 0 ;
  maxsep = nlabels = npix = des = asc = nowpts = ntimes = 0 ;

  // calculate route properties 

  for(s0=0;s0<segments.length;s0++) 
  { nowpts += segments[s0].data.length ;

    for(oalt=null,s1=0;s1<segments[s0].data.length;otime=time,s1++)
    { if((alt=segments[s0].data[s1].h)==null) nnull += 1 ; 
      else
      { if(oalt!=null) 
        { if(alt>oalt) asc += alt-oalt ; else des += oalt - alt ; } 
        oalt = alt ;
      }
      if(segments[s0].data[s1].type!=null) nlabels += 1 ;
      npix += segments[s0].data[s1].photo.length ;

      time = segments[s0].data[s1].t ;
      if(time!=null) { time = time.getTime() ; ntimes += 1 ; }
      if(tlast!=null&&time!=null&&time<tlast) outoforder = 1 ; // out of order
      if(time!=null) tlast = time ;

      if(s1) 
      { sep = dist(segments[s0].data[s1-1].pos,segments[s0].data[s1].pos) ;
        dd += sep ; 
        if(sep>maxsep) maxsep = sep ; 
        if(time!=null&&otime!=null) 
        { tdist += sep ; ttime += (time-otime)/3600 ; }
      }
    }
  }

  // title
  d.appendChild(titlediv('Title',rp.title,50)) ;

  // stars
  starsdiv = document.createElement('div') ; 
  starsdiv.setAttribute('style','color:#0000bd') ; 
  d.appendChild(starsline(rp.stars,1)) ;

  /* ------------------------------------------------------------------------ */
  /*page*/
  /* ------------------------------------------------------------------------ */

  // description
  if(rp.desc==null) 
  { tdiv = document.createElement('div') ; 
    tdiv.appendChild(document.createTextNode('[')) ;
    s = genclickable('retitle("description")','Add description',']br') ;
    tdiv.appendChild(s) ;
  }
  else tdiv = titlediv('Description',rp.desc,50) ;
  d.appendChild(underline(tdiv)) ;

  // last added route
  if(loadno>0)
  { s = '\u00a0\u00a0\u00a0Last added route' ;
    if(pr.title==null) s += ':' ; else s += ' (' + pr.title + '):' ;
    d.appendChild(genspan(s,'br')) ;
    s = '\u00a0\u00a0\u00a0' ;
  }
  else s = '' ;

  // number of track points and optimisation
  s += 'Track points on input: ' + pr.inputlen ; 
  if(pr.optim.already)  
    d.appendChild(genspan(s+' (previously optimised)','br')) ;
  else if(pr.optim.ndel==0) 
  { d.appendChild(genspan(s+' [')) ;
    if(nactions==loadno+1) 
      d.appendChild(genclickable('optimprompt()','Optimise','br')) ;
    else d.appendChild(genspan('Optimise',null,'color:silver','br')) ; 
  }
  else d.appendChild
         (genspan(s+', optimised to '+(pr.inputlen-pr.optim.ndel),'br')) ;
  if(!pr.optim.already&&pr.inputlen-pr.optim.ndel!=nowpts) 
    d.appendChild(genspan('Now ' + nowpts + ' track points','br')) ;

  // are there any missing altitudes?
  if(nnull) 
  { d.appendChild(genspan(nnull+' points have no associated altitudes [')) ;
    d.appendChild(genclickable('getalts(0)','Find altitudes',']br')) ;
  }

  // are points timed and are they in sequence?
  if(outoforder==0) 
  { if(ntimes==0) d.appendChild(genspan('No timings provided','br')) ;
    else 
    { if(ntimes>=nowpts) s = '[' ;
      else s = (nowpts-ntimes) + ' points have no associated timings [' ;
      d.appendChild(genspan(s)) ;
      d.appendChild(genclickable('deltimes()','Discard timings',']br')) ;
    }
  }
  else d.appendChild(genspan('Times are out of sequence (will be '+
                             'discarded on download)','br')) ;

 // labels and photos
  if(nlabels>0) d.appendChild
    (genspan(nlabels+' labelled course point'+(nlabels>1?'s':''),'br')) ; 
  if(npix>0) d.appendChild(genspan(npix+' photo'+(npix>1?'s':''),'br')) ; 

  /* ------------------------------------------------------------------------ */
  /*page*/
  /* ------------------------------------------------------------------------ */

  // unsaved changes
  unsaved = unsavedchanges.length ; 
  if(unsaved>0) d.appendChild
    (genspan(unsaved+' unsaved change'+(unsaved>1?'s':''),'br')) ;

  // number of segments - option to combine
  if(segments.length>1) 
  { d.appendChild(genspan(segments.length + ' segments [')) ;
    d.appendChild(genclickable('combine()','Combine',']br')) ;
    d.appendChild(genspan('Note that segments must be combined before saving',
                          'br','font-style:italic')) ;
  }
  
  // max waypoint separation - option to interpolate
  d.appendChild
    (genspan('Max waypoint separation: '+maxsep.toFixed(0)+'m','br')) ;
  if(maxsep>=100) 
  { d.appendChild(genspan('Note that separations \u003e100m are illegal '+
                          'on Garmin','br','font-style:italic')) ;
    d.appendChild(genspan('\u00a0\u00a0\u00a0[')) ;
    d.appendChild(genclickable('extrapts()','Interpolate extra points',']br')) ;
  }

  // distance / ascent / descent / average speed
  tdiv = document.createElement('div') ; 
  tdiv.setAttribute('style',
    'margin-top:2px;border-top:solid 1px silver;padding-top:2px') ; 

  function ldivadd(b)
  { ldiv.appendChild(document.createTextNode(b)) ;
    ldiv.appendChild(document.createElement('br')) ;
  } ;

  ldiv = document.createElement('div') ; 
  ldiv.setAttribute('style','float:left;padding-right:8px') ; 
  ldivadd('Total distance:') ;
  ldivadd('Total ascent:') ;
  ldivadd('Total descent:') ;
  if(outoforder==0&&tdist>0&&ttime>0) ldivadd('Average speed:') ;
  tdiv.appendChild(ldiv) ; 

  ldiv = document.createElement('div') ; 
  ldiv.setAttribute('style','float:left;text-align:right') ; 
  ldivadd((dd/1000).toFixed(3)) ;
  ldivadd(asc.toFixed(0)) ;
  ldivadd(des.toFixed(0)) ;
  if(outoforder==0&&tdist>0&&ttime>0) ldivadd((tdist/ttime).toFixed(1)) ;
  tdiv.appendChild(ldiv) ; 

  ldiv = document.createElement('div') ; 
  ldiv.setAttribute('style','float:left;padding-left:2px') ; 
  ldivadd('km') ;
  ldivadd('m') ;
  ldivadd('m') ;
  if(outoforder==0&&tdist>0&&ttime>0) ldivadd('km/hr') ;
  tdiv.appendChild(ldiv) ; 

  d.appendChild(tdiv) ; 
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function starsline(nstars,editable)
{ var i , c , s , d ;
  if(editable) 
  { if(starsdiv==null) return ; else d = starsdiv ; 
    while(d.childNodes.length>0) 
      d.removeChild(d.childNodes[d.childNodes.length-1]) ;
  }
  else d = document.createElement('div') ; 
 
  for(i=1;i<=5;i++) 
  { s = document.createElement('span') ;
    if(nstars==null||i>nstars) c = '\u2606' ; else c = '\u2605' ;
    s.appendChild(document.createTextNode(c)) ;
    if(editable&&i!=nstars) 
    { s.setAttribute('style','cursor:pointer') ; 
      function createfunc(i) { return function() { restars(nstars,i) ; } } ;
      s.onclick = createfunc(i) ; 
    }
    d.appendChild(s) ; 
  }
  if(editable&&nstars!=null) 
  { d.appendChild(genspan(' [')) ; 
    d.appendChild(genclickable('restars('+nstars+',null)','Clear')) ; 
    d.appendChild(genspan(']')) ; 
  }
  return d ; 
}
/* --------------------------------- photo info ----------------------------- */

function phdiv(i) 
{ var hind,ind , list=imginfo.list , d = pixinfodiv(list,i,imginfo.sizes) ;

  if(imginfo.pixpage!=null)
  { d.appendChild(genlink(imginfo.pixpage,'Full photo set',1)) ;
    d.appendChild(genspan(' (opens in new tab/window)','br')) ;
  }

  if(list[i].retid!=null)
  { for(hind=null,ind=0;ind<=i;ind++) 
      if(list[ind].retpage!=undefined&&list[ind].retpage!=null) 
        hind = list[ind].retpage + '.html#' + list[i].retid ; 
    if(hind!=null) 
    { d.appendChild(genlink(hind,'Route notes',1)) ;
      d.appendChild(genspan(' (opens in new tab/window)','br')) ;
    }
  }
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*          FUNCTIONS FOR COMPUTING & DISPLAYING THE ALTITUDE PROFILE         */
/* -------------------------------------------------------------------------- */

function profiletype(x,y,sind)
{ var i,ymin,ymax  ;
  this.x = x ; 
  this.y = y ; 
  this.sind = sind ; 
  this.sum = x[x.length-1] ;
  for(ymin=ymax=null,i=0;i<y.length;i++) if(y[i]!=null) 
  { if(ymax==null||y[i]>ymax) ymax = y[i] ;
    if(ymin==null||y[i]<ymin) ymin = y[i] ;
  }
  if(ymin>0) { if(ymax>3*ymin) ymin = 0 ; else ymin *= 1 - (ymax/ymin-1)/2 ; }
  this.ymin = ymin ;
  this.ymax = ymax ; 
  this.yspan = Math.max(1,ymax-ymin) ; 
  this.prodiv = this.curdiv = this.curcan = this.curhandle = null ; 
}
// member functions
profiletype.prototype.getx = function(x) { return 10 + 600 * x / this.sum ; } ;
profiletype.prototype.locx = function(i) { return this.getx(this.x[i]) ; } ;
profiletype.prototype.gety = function(y) 
{ return 10 + 180*(this.ymax-y)/this.yspan ; } ;

profiletype.prototype.getxy = function(i)
{ return [this.getx(this.x[i]),this.y[i]==null?null:this.gety(this.y[i])] ; } ;

profiletype.prototype.getsel = function(x)
{ var lo=0,hi=this.x.length-1,m,s0 ;
  if(x<0) x = 0 ; else if(x>600) x = 600 ;
  x += 10 ; 
  while(hi>lo+1)  // binary search
  { m = Math.floor((lo+hi)/2) ; if(this.locx(m)>x) hi = m ; else lo = m ; }
  if(Math.abs(this.locx(lo)-x)<Math.abs(this.locx(hi)-x)) m = lo ; else m = hi ;
  for(s0=0;s0<this.sind.length-1&&this.sind[s0+1]<=m;s0++) ;
  return [ s0 , m-this.sind[s0] ] ;
} ;
/* -------------------------------------------------------------------------- */

function procoords(segments)
{ var n,x,y,s0,s1,sum,len,pos,oldpos,sind,i ;
  for(n=s0=0;s0<segments.length;s0++) n += segments[s0].data.length ;
  x = new Array(n) ;
  y = new Array(n) ;
  sind = new Array(segments.length+1) ;
  x[0] = 0 ; 

  for(ymax=ymin=null,sum=i=s0=0;s0<segments.length;s0++) 
    for(sind[s0]=i,len=segments[s0].data.length,s1=0;s1<len;s1++,i++)
  { y[i] = segments[s0].data[s1].h ; 
    pos = segments[s0].data[s1].pos ;
    if(i) sum = x[i] = sum + dist(pos,oldpos) ; 
    oldpos = pos ; 
  }
  sind[segments.length] = n ; 
  if(n==0) return null ; else return new profiletype(x,y,sind) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function drawpro(pro)
{ var div=document.createElement('div'),c=document.createElement('canvas') ;
  var ctx,i,n,s0,s1,len,xinit,ox,step,xy ;

  div.setAttribute
    ('style','position:absolute;height:190px;right:0;top:0;width:620px') ; 
  c.setAttribute('width',620) ; 
  c.setAttribute('height',200) ; 
  div.appendChild(c) ;
  ctx = c.getContext("2d") ; 
  ctx.font = "10px Helvetica" ;
  ctx.lineWidth = 0 ; 
  ctx.globalAlpha = 0.6 ; 

  ctx.fillStyle = 'lightgray' ;
  ctx.rect(0,0,620,200) ;
  ctx.fill() ; 

  // draw a profile of each segment
  for(i=n=s0=0;s0<pro.sind.length-1;s0++) 
  { len = pro.sind[s0+1] - pro.sind[s0] ;
    if(s0&1) ctx.fillStyle = "#ff9999" ; else ctx.fillStyle = "#ff0000" ; 
    for(xinit=ox=null,s1=0;s1<len;s1++,n++,ox=xy[0])
    { xy = pro.getxy(n) ; 
      if(xy[1]!=null) 
      { if(xinit==null) 
        { ctx.beginPath() ; ctx.moveTo(xy[0],xy[1]) ; xinit = xy[0] ; }
        else ctx.lineTo(xy[0],xy[1]) ; 
      }
    }
    ctx.lineTo(xy[0],190) ; 
    ctx.lineTo(xinit,190) ; 
    ctx.closePath() ; 
    ctx.fill() ; 
  }

  // lines
  if(pro.yspan>2500) step = 1000 ; 
  else if(pro.yspan>1250) step = 500 ;
  else step = 100 ; 

  for(i=step*Math.floor(pro.ymin/step+1);i<pro.ymax;i+=step) 
  { y = 0.5 + pro.gety(i) ;
    ctx.beginPath() ; 
    ctx.lineWidth = 1 ; 
    ctx.strokeStyle = '#555' ; 
    ctx.moveTo(10,y) ;
    ctx.lineTo(610,y) ; 
    ctx.stroke() ; 
    ctx.strokeText(i,590,y-2) ;
  }
  pro.prodiv = div ; 

  // cursor
  pro.curdiv = document.createElement('div') ;
  pro.curdiv.setAttribute
    ('style','position:absolute;height:190px;right:0;top:0;width:620px') ; 
  pro.curcan = null ; 
  pro.curhandle = pro.curdiv.addEventListener("click",function(e)
  { var pos = e.clientX - (window.innerWidth-610) ; 
    if((pos-594)*(pos-594)+(e.clientY-16)*(e.clientY-16)<200)
    { unprofile() ; return ; } 
    drawsel(0,pro.getsel(pos)) ; 
  } ) ;  
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function drawxcur(pro,sel)
{ if(pro==null||pro.prodiv==null) return null ; 
  var pos = pro.getx(pro.x[sel[1]+pro.sind[sel[0]]]) , i ; 
  var canvas=document.createElement('canvas') , ctx=canvas.getContext("2d") ; 
  canvas.setAttribute('width',620) ; 
  canvas.setAttribute('height',200) ; 
  ctx.beginPath() ; 
  ctx.lineWidth = 1 ; 
  ctx.moveTo(pos,10) ;
  ctx.lineTo(pos,190) ; 
  ctx.stroke() ; 

  // the circle of the 'x' 
  ctx.beginPath() ; 
  ctx.strokeStyle = '#555' ; 
  ctx.fillStyle = 'white' ; 
  ctx.lineWidth = 3 ; 
  ctx.arc(604,16,14.1,0,2*Math.PI,false) ;
  ctx.stroke() ; 
  ctx.fill() ; 

  for(i=6;i<=26;i+=20) // the two bars of the 'x'
  { ctx.beginPath() ; 
    ctx.moveTo(594,i) ; 
    ctx.lineTo(614,32-i) ; 
    ctx.stroke() ; 
  }
  if(pro.curcan!=null) pro.curdiv.removeChild(pro.curcan) ; 
  pro.curdiv.appendChild(pro.curcan=canvas) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------- actionname ------------------------------ */

function actionname(x)
{ var i,s ; 
  if(x[0]=='bin') return 'delete segment' ; 
  if(x[0]=='snip') return 'split segment' ; 
  if(x[0]=='editlabel') 
  { if(x[4]=='') return 'delete label' ; 
    else if(x[3]=='') return 'label waypoint' ;
    else return 'edit label' ; 
  }
  if(x[0]=='edittitle') return 'edit title' ; 
  if(x[0]=='editdescription') return 'edit description' ; 
  if(x[0]=='wpdel') return 'delete waypoint' ; 
  if(x[0]=='move') 
  { if(x[5]) return 'insert waypoint' ; else return 'drag waypoint' ; }
  if(x[0]=='recal') return 'recalibrate altitudes' ; 
  if(x[0]=='setalt') return 'set waypoint altitude' ; 
  if(x[0]=='resign') return 'change label symbol' ; 
  if(x[0]=='combine') return 'combine '+x[1]+' segments' ; 
  if(x[0]=='revseg') return 'reverse segment' ; 
  if(x[0]=='optimise') return 'optimisation' ; 
  if(x[0]=='deltimes') return 'delete times' ; 
  if(x[0]=='editphoto') 
  { if(x[5]==null) return 'delete photo' ; 
    else if(x[4]==null) return 'add photo' ;
    else return 'change photo' ; 
  }
  if(x[0]=='extra') return 'interpolate extra points' ; 
  if(x[0]=='stars'&&x[2]==null)
  { s = 'clear ' ; for(i=0;i<x[1];i++) s += '\u2605' ; return s ; } 
  if(x[0]=='stars')
  { s = 'set ' ; for(i=0;i<x[2];i++) s += '\u2605' ; return s ; } 
  alert('Unrecognised action: '+x[0]) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- getalts -------------------------------- */

var elevator=null,reqlist=[] ; 

function getalts(thresh)
{ var s0,s1,start,end,n,npts,flag,i,lox,ind,point ; 
  if(thresh==0) { infowindow.close() ; thresh = 1 ; } 
  if(reqlist.length>0) return ;
  if(elevator==null) elevator = new google.maps.ElevationService ;

  for(reqlist=[],flag=npts=s0=0;s0<segments.length;s0++)
    for(s1=0;s1<segments[s0].data.length;s1++) if(segments[s0].data[s1].h==null)
  { if(s1>0) start = s1-0 ; else start = s1 ; 
    for(;s1<segments[s0].data.length&&segments[s0].data[s1].h==null;s1++) ;
    if(s1==segments[s0].data.length) end = s1 ; else end = s1+1 ; 
    n = end - start ; 
    if(npts+n<=500) { npts += n ; reqlist.push([s0,start,end]) ; }
    else if(reqlist.length>0) { flag = 1 ; break ; }
    else
    { for(point=new Array(501),i=0;i<=500;i++)
        point[i] = segments[s0].data[start+Math.floor((i*(n-1))/500)] ;
      reqlist.push([s0,0,501]) ;
      flag = 2 ; 
      break ; 
    }
  }
  if(flag==0&&npts<thresh) { reqlist = [] ; return ; }

  if(flag!=2) for(point=new Array(npts),i=ind=0;ind<reqlist.length;ind++) 
  { s0 = reqlist[ind][0] ;
    start = reqlist[ind][1] ;
    end = reqlist[ind][2] ;
    for(s1=start;s1<end;s1++,i++) point[i] = segments[s0].data[s1] ;
  }
  for(lox=new Array(point.length),i=0;i<point.length;i++) 
    lox[i] = point[i].pos ;

  elevator.getElevationForLocations( {locations:lox} , function (results,status)
  { // assume that the results come in sequence, ie. correspond to xpending[0]
    var d0,dn,k ;
    if(status===google.maps.ElevationStatus.OVER_QUERY_LIMIT)
      alert('No calibration data available: over Google query limit') ;  
    else if(status===google.maps.ElevationStatus.INVALID_REQUEST)
      alert('Invalid calibration request') ;  
    else if(status===google.maps.ElevationStatus.REQUEST_DENIED)
      alert('Calibration request denied') ;  
    else if(status===google.maps.ElevationStatus.UNKNOWN_ERROR)
      alert('Unknown error reported for calibration request:'+status) ;  
    else if(status!==google.maps.ElevationStatus.OK)
      alert('Calibration error') ;  
    if(status!==google.maps.ElevationStatus.OK) throw '' ;

    if(results.length!=lox.length) flag = 1 ; 
    else for(flag=i=0;i<lox.length&&flag==0;i++) 
      if(dist(lox[i],results[i].location)>5) flag= 1 ; 
    if(flag) 
    { alert("elevation response does not correspond to request") ; return ; }

    for(k=ind=0;ind<reqlist.length;ind++,k+=n) 
    { n = reqlist[ind][2] - reqlist[ind][1] ; 
      if(point[k].h!=null&&point[k+n-1].h!=null)
      { d0 = point[k].h - results[k].elevation ;
        dn = point[k+n-1].h - results[k+n-1].elevation ;
        for(i=1;i<n-1;i++) 
          point[k+i].h = results[k+i].elevation + (i*dn+((n-1)-i)*d0) / (n-1) ;
      }
      else
      { if(point[k].h!=null) d0 = point[k].h - results[k].elevation ; 
        else if(point[k+n-1].h!=null) 
          d0 = point[k+n-1].h - results[k+n-1].elevation ; 
        else d0 = 0 ; 
        for(i=0;i<n;i++) point[k+i].h = results[k+i].elevation + d0  ;
      }
    }
    reqlist = [] ; 
    getalts(thresh) ; 
  } ) ;
}

routemaster.js routemasterlib.js tcxlib.js
pix.js: pixlib.js