Colin Champion

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

user doc : hints : linking to tracks via routemaster : technical doc : about the software : routemasterlib doc : Garmin 500 review : source : download & index

• general functions     • recent updates     • editing functions     • adding photos to your own instance     • creating a route index    

Routemaster may be used to display and edit GPS tracks and to display multi-track indexes. Photographs may be included. The GPS tracks may be in TCX, GPS or FIT format, and you can either upload a track from your own computer or new Aug 2017 view a track stored anywhere on the web by specifying its location in the url bar.

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 go to www.masterlyinactivity.com/routemaster and follow the prompts, either browsing your file system or entering a URL.

Alternatively, if the track is on the web, you can specify its location as part of the routemaster URL by invoking

http://www.masterlyinactivity.com/routemaster/?track=fullURLhere

So since Garmin’s sample TCX track is at

https://developer.garmin.com/downloads/connect-api/sample_file.tcx

the URL you need to type is

http://www.masterlyinactivity.com/routemaster/?track=https://developer.garmin.com/downloads/connect-api/sample_file.tcx

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

For an example of a route incorporating photos, see the Monte Tondo circuit we rode in Tuscany in June 2017.

Since routemaster allows you to import a route as FIT and download it as TCX/GPX it can be used as a conversion tool. This is not recommended. For a trustworthy conversion you should use a tool which aims to preserve data integrity (eg. Gpsies). routemaster preserves only those features which can be edited for navigation purposes.

• going to the startpoint of a track     • viewing photos     • changing the startpoint of a track     • rectifying post-prandial notches     • incorrectly printed photo lists     • editing     • putting a version on the web     • uploading and downloading    

Hit [shift][right arrow key]. If the route has more than one segment, you may have to do this more than once.

Click on the camera icon to bring up a thumbnail. The menu gives you the options to edit the photo designator, to view some basic info, and to enlarge the photo to full size. To see full-size photos at their best, use routemaster in full-screen mode (requested by an icon at bottom-right of the screen). When you are looking at a full-size photo, the left and right arrow keys take you to the preceding/following photos, the up and down arrow keys enlarge and reduce, and the return key takes you back to the GPS track.

If you have a (roughly) circular route and want to change its start point:

Post-prandial notches are described below. If you see an implausible notch (or less commonly, tabletop) in your altitude profile, I can’t think of anything better to do than to correct it using the Google elevation service.

Firstly use the scissors to define a segment embracing the notch. You can perform fine adjustments by invoking ‘Waypoint info’ on the first or last waypoint of a segment. When you’re happy that all the unreliable points are in the same segment, and that this segment contains as few reliable points as possible, select a waypoint in the unreliable segment, go to ‘Segment info’ and request correction from the Google elevation service. Then combine the segments again (under the cogwheel).

Occasionally, if I load a photolist through the routemaster URL, eg

http://www.masterlyinactivity.com/routemastertest/?track=../temp/fits/2017-06-20-09-40-45.fit&list=../../garfagnana/list.xml

then when I download the edited track I get a misprinted photo list field, eg.

<PhotoList src="http://garfagnana/list.xml" />

This is not a bug in routemaster but arguably a bug in Firefox. The cause is that the URL as I entered it is wrong – I should have written

http://www.masterlyinactivity.com/routemastertest/?track=../temp/fits/2017-06-20-09-40-45.fit&list=../garfagnana/list.xml

Firefox silently corrects the error, leaving you imagining you’ve supplied the right number of ‘../’s, but you haven’t, and it’s no job of routemaster to detect and silently correct your error.

Keep a copy of the original, at least for a while. You never know what may go wrong. If the original is a .FIT file so much the better: it’s compact; you won’t mistake it for an edited version; and routemaster keeps a track of its name when editing.

It’s best to use a version that has been through at least nominal editing in routemaster, if only for the sake of the optimisation which brings the file down to a reasonable size.

It’s a good idea to add a title at the same time and to attend to any waypoints which are clearly erroneous. If the route starts from your garden shed and you don’t want unexpected visitors, it may be prudent to truncate the track a little.

It’s better to upload GPX than TCX because the file is smaller; obviously anyone can download in either format. (But there’s no problem of bloat in TCX indexes: they can’t be used for navigation so unnecessary fields aren’t retained for the sake of humouring Garmin.) If you put an optimised version on the web then it won’t be reoptimised by users who view it, guaranteeing that they see what you expect. If you put an unoptimised version on the web and refer to specific waypoints then users are likely to get confused.

When web tracks are slow to load this is because of their physical size. They have to traverse the internet twice to defeat the same origin policy and they may start from a slow server. Keeping them small will keep your users happy.

I have done all that is possible to reduce the delay: the GPS track is loaded as soon as its URL is available and other operations take place in parallel.

To load a track into a Garmin:

Formatting errors in Garmin’s eyes include the absence of any fields whose presence it insists on (with or without justification) as well as more obvious errors such as unclosed tags, double quotes etc. 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.

The format of a uri for viewing a GPS track through routemaster is given above. If you construct a uri in this format you can use it as a link in a web page or an email.

This is a free service, in that my web account incurs all the costs of displaying your track. These costs are trivial for now, but I cannot guarantee continued provision of a free service if they become appreciable (which may happen if Google start charging for use of their maps API). The method of showing data from one website in an application on another requires a little fancy footwork and may break down in some cases. I was surprised to find that it worked for the Garmin example track (which is accessed via ‘https://’), and it probably won’t work for all secure sites. Over-zealous firewalls may cause difficulties. This element is exposed to some risk of abuse.

The method of adding photos requires a certain amount of work. 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. Make sure it works by creating a pix.html page to view the pictures. The work you’ve put in has now received a little reward.

The next step is to 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.masterlyinactivity.com/routemaster/?list=http://www.mysite.org.uk/ruritania/list.xml

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

www.masterlyinactivity.com/routemaster/?track=http://www.mysite.org.uk/ruritania/greasyspoonride.tcx&list=http://www.mysite.org.uk/ruritania/list.xml

Here the part following the ‘list=’ is the uri of the photo list.

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. If you upload the track to the web and view it in routemaster the photos will be embedded within it.

I wish I could say that this was plain sailing but I often have difficulties myself. But the process is gradually becoming less painful.

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 route title which will apply to 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’ (under the cogwheel) and the index file will appear on your computer. If you upload it to your own web space and view it through routemaster it will now display as a true index.

If the tracks were loaded into routemaster from their URLs then the track URLs stored in the index will be correct, but if they were loaded from your own disc through the file load, routemaster cannot determine the corresponding web location so it makes up a dummy with the placeholder ‘$FILE$’ substituted for the unknown URL component. A global replace within an editor will correct it for the web.

It is useful for a track to link to its index. You will then be pointed to the index from the Route Info menu. The link has to be added by hand-editing the route, adding the line

    <Index href="uri"/>

for TCX or

    <index href="uri"/>

for GPX amongst the extensions after the track points. The uri should be complete, eg.

http://www.masterlyinactivity.com/routemaster/?track=http://www.masterlyinactivity.com/routemaster/routes/grancanaria/index.tcx

• 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.

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 (though tracks loaded from the web pass through 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.

Points may not have altitudes either because a route was input from a defective GPS track or because waypoints have been added or moved. Whenever a point is moved or added its altitude is set to null, and when enough null altitudes have been introduced to justify a call to the elevation service a call is made. But whenever a download is initiated routemaster tries to fill in all missing altitudes, however small the resulting batch.

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.

I have tried various modifications to my GPX output in the hope of getting my Garmin to accept it, but without success. I have also tried transferring GPX tracks from a variety of sources: Garmin Connect, bikehike, ridewithgps, gpsies and gpsbabel. None has been accepted.

What happens in each case is that the file in ‘New Files’ is left untouched, whereas when a TCX file is misformatted it is deleted without a FIT copy being made in the ‘Courses’ directory. So it appears that the Garmin isn’t even willing to attempt to process GPX data.

Some people have reported otherwise. It may be that they’ve managed to miss a firmware update.

• 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. I suspected at the time that it was 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 recourse.

But a year later nothing much has changed. I suspect that Google were losing the ability to track users of Google maps through the increasing popularity of ad blockers. Requiring a key allows them to analyse usage by application. Maybe they’ll take pecuniary advantage of this in the end.

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    • Edge 500 FIT track    remote tcx file

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 early in 2016.)

routemasterlib.js (but not routemaster.js, routemasteredit.js or routemasterui.js) is supplied free of charge under an MIT licence (giving you carte blanche). No licence is given in respect of routemaster.js, routemasteredit.js and routemasterui.js, but you may freely use the utility www.masterlyinactivity.com/routemaster.

Since routemasterlib.js is made available for anyone to use, it has some basic documentation (following).

[The relatively restrictive terms here reflect uncertainty over how access to Google maps will be supported in future. If they start charging then the balance of routemaster code may tilt to the server side, so I need to retain scope to extend it in this way.

routemasterlib.js is treated more liberally because it is usable by anyone whatever terms Google attach to their maps API. The same applies to pix.js and pixlib.js.]

• internal track format     • route properties object     • readtcx     • readgpx     • readfit     • writegps     • writeindex     • optimise     • getalts     • profilemaptype     • utilities

These functions are documented for information. Since they are available on an MIT licence you are free to use or modify them. But don’t assume that the interface will remain compatible: I consider myself free to change it. If you want to use them, make your own copy.

A track is stored internally as an array of segments (which are the routes in the case of an index). A segment comprises:

A point is a datatype object. It comprises a number of data fields, many of which are null unless the point is also a course point or has a photo associated with it.

Not yet documented (maybe not settled).

readtcx is the function to read a tcx course. The call is

readtcx(xmldoc,xmlfile)

where xmldoc is the parsed contents of the XML file (as parsed by the parseFromString method of a DOMParser) and xmlfile is the uri from which the xmldoc was obtained.

Its return value is an object with 3 fields:

The only case in which there is more than one segment is that of index files. For indexes the global title differs from the title of each route, and each route has separate properties. For non-index files the global title is the route title and the segment properties are transferred to the routeprops variable and properties of individual segments no longer maintained.

xmlfile is needed as an argument to readtcx because URIs in the GPS track are relative to the track location, but when they are returned by readtcx they have been adjusted to be relative to the host page.

For historical reasons routemasterlib supports some synonyms for routemaster extensions.

readgpx is the reader for gpx files.

readgpx(xmldoc,m,flagsel,photosel,cyclist,xmlfile)

It is the same as readtcx except for not having a title field in its return value, and for the fact that arrays of segments and their associated properties are necessarily 1 long.

readfit is the reader for fit files, copied from mygpsfiles.

readfit(rawdata,m)

where rawdata is an array buffer read by the FileReader readAsArrayBuffer method (which is equivalent to the XMLHttpRequest response field).

The return value is as for readgpx.

writegps is the writer for tcx/gpx files.

writegps(props,data,mode)

where

The return value is a character string containing an XML representation of the route (nothing is actually downloaded – you have to do this yourself with the returned string.

writeindex writes a tcx index of gps tracks.

writeindex(segments,title,list,parms)

where

The return value is a character string containing an XML representation of the index.

getalts is a function which makes use of the Google maps elevation service to fill in any null altitudes. The call is:

getalts(segments,thresh,action,doneaction)

where

getalts ignores the division into segments and treats the route as a linear array of points. If the number of null altitudes is less than thresh it returns taking no action. Otherwise it groups missing altitudes into batches of at most 500 points, calling on the elevation service to supply the missing altitudes. Once the number of remaining missing altitudes is <thresh it ceases processing.

The operation of getalts is asynchronous. A request will be submitted to the elevation service as soon as you call it, but the response will come after some delay, and only then will the track values be updated. However given the way Javascript assigns objects (i.e. by address), the adjustment should be performed correctly even if you have edited the track in the meantime.

Every time a new set of altitudes is received, getalts invokes your action function (if supplied and non-null) with a single parameter, namely the number of altitudes supplied by Google in the latest call.

If you call getalts while it is awaiting a response from an earlier request to the altitude service, it will not make a fresh request. However you may want to tighten the batching; for instance, having previously called getalts(100), which is guaranteed to bring the number of null altitudes below 100, you may now want to fill in all null altitudes. So if you pass a thresh lower than the previous one, this will force the function to keep iterating until the new threshold has been attained.

Likewise you may have invoked getalts without a doneaction callback but now want it to invoke a given function on completion. So if you now call it with the new function as fourth parameter, this new function will be adopted as the termination callback. If you pass null as the fourth parameter (rather than omitting the parameter completely) then you suppress the callback. Hence the call

  getalts(null,null,null,null) ;

turns off any pending doneaction callback.

In order to ensure that the database altitudes are consistent with those already existing in the track (which may be subject to a calibration error) getalts performs an adjustment. It includes the last preceding point with a non-null altitude (if any) and the first following point with a non-null altitude (if any) in the request. Then the difference between your own altitude for the preceding point and the Google estimate is taken as the initial calibration error, and the final calibration error estimated likewise; and these errors are used to smoothly adjust the values returned by Google.

This isn’t ideal and may be unnecessary. It can only be beneficial for GPS tracks recorded on a device which stores barometric altitude estimates with no GPS correction. I don’t know if any such devices exist. On the other hand it’s unlikely to do much harm.

If a stretch of unknown altitudes is longer than 500, then rather than break it into substretches I select 500 points regularly along it and linearly interpolate the altitudes returned. [This probably hasn’t been tested.]

optimise reduces the track to the smallest number of points while retaining shape. The algorithm is sketched above. The call is

optimise(data,parms)

where

parms has four fields:

The return value is a reduced array of points.

A point is not eligible for deletion from a track if either its type field is non-null or its photo field, assumed to be an array, has length >0. The pos field of a point is assumed to contain its position (as a googleLatLng) and its h field its altitude. You may pass an array of points defined however you want so long as these properties apply.

profilemaptype is the constructor for an object containing a numerical representation of an altitude profile. Invoke it as

  pro = profilemaptype(segments,) ;

where

The returned object has the following fields:

hmin may not be the true minimum but instead may be 0 if the profile will be more meaningful that way.

gencolours(n) returns an array of n character strings which will be used as track colours in an index. The colours are intended to be reasonably well spaced and not too pale or too dark. Moreover the first colour is the same regardless of n, the second is the same for all n>1, and so forth.

ascify(s2) returns a character string corresponding to s but with diacritics deleted (for filenames etc.).

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.

Are future models likely to be better? I’m afraid the opposite may be true. Instead of improving the device Garmin are pursuing changing fashions in use: first downloadable maps (useless off-road), now Strava-type competitive riding, and who knows what next.

When you turn the GPS on you get uncalibrated barometric readings. Meanwhile it monitors the difference between barometric and GPS estimates. When it has enough data to calibrate the barometer it starts adding the calibration term to the barometric readings. Consequently the displayed altitude varies rapidly as the calibration term comes into effect. This gives rise to the post-prandial notch you see in altitude profiles when you switch the Garmin off for a meal break.

So far as I can see this problem does not arise at the start of a ride. So the problem may be related to the missing ‘motion detected’ message after lunch: some operations which should be performed every time you power up are only performed at the beginning of a track.

You don’t get a hint of this (or of other complexities) in the supplied documentation. It’s impossible to use the unit correctly without knowing what’s going on.

• routemaster.js     • routemasteredit.js     • routemasterui.js     • routemasterlib.js    

• function     • resizer     • genseg     • dotpath     • linepath     • interp     • bearing     • greyout     • blackout     • enterFullscreen     • exitFullscreen     • findimg     • unsavedmsg     • selpoint     • highlight     • unhighlight     • getbtnpos     • walkto     • keystroke     • shiftkey     • undraw     • redraw     • recolour     • obliterate     • drawprofile     • draw     • disconnect     • reconnect     • connect     • redrawconnect     • drawsel     • checklostedits     • genpage     • getlist     • filedialogue     • render     • retitle     • restars     • genbutton     • addload     • dl     • canceldl     • indexdl     • confirmeddl     • semiconfirmeddl     • reconfirmeddl

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

var defparms = {tol:15,maxsep:95,wppenalty:700,vweight:1} ;
var indparms = {tol:500,maxsep:200000,wppenalty:500000,vweight:0} ;
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')
      { unhighlight() ; infowindow.handle = null ; }
      else if(infowindow.type=='phinfo') walkto(selected[0],selected[1]) ;
      infowindow.type = starsdiv = pendingdl = null ; 
    } ) ;
    this.type = type ; 
  } , 
  close: function() 
  { if(this.handle==null) return null ; 
    var response = this.type ;
    this.handle.close() ; 
    if(response=='highlight') unhighlight() ; 
    else if(response=='phinfo') walkto(selected[0],selected[1]) ; 
    this.handle = this.type = starsdiv = pendingdl = null ; 
    return response ; 
  } 
} ; 
/* -------------------------------------------------------------------------- */

function resizer()
{ if(imgdiv!=null) return ;
  var isfullscreen = queryfullscreen() , fse ; 
  if(mapparent==null||body==null) 
    mapparent = body = document.getElementsByTagName("body")[0] ;
  if(isfullscreen>0&&wasfullscreen==0&&mapparent==body&&pro!=null)
  { fse = document.fullscreenElement || document.mozFullScreenElement
       || document.webkitFullscreenElement || document.msFullscreenElement  ;
    if(fse!=undefined&&fse!=null&&fse!=body)
    { mapparent = fse ; 
      if(pro!=null)
      { body.removeChild(pro.prodiv) ; fse.appendChild(pro.prodiv) ; 
        body.removeChild(pro.curdiv) ; fse.appendChild(pro.curdiv) ; 
      }
      window.removeEventListener('resize',resizer) ; 
    }
  }
  wasfullscreen = isfullscreen ;
}
/* --------------- 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 ; 
}
/* -------------------------------------------------------------------------- */
/*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((indexmode&&(btn.index==2||btn.index==4))||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.ui.title = btn.greytitle ; 
  btn.active = 0 ; 
  return 1 ; 
}
function blackout(btn)
{ if((indexmode&&(btn.index==2||btn.index==4))||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.ui.title = btn.blacktitle ; 
  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(indexmode==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(segments.length>1) blackout(binbtn) ; 
  if(imginfo.uri!=null&&imginfo.uri!=undefined) 
    thind = thumbind(imginfo.sizes) ;
  scroll = highdiv(s0,segments[s0].props,imginfo.list,imginfo.sizes,
                   thind,segments[s0].props.photo) ;
  scroller = scroll.scroller ;
  infowindow.open(scroll.div,northernmost(segments[s0].data),'highlight') ; 
}
function unhighlight()
{ redraw(selected[0]) ; 
  if(scroller!=null) { clearInterval(scroller) ; scroller = null ; }
  infowindow.handle = null ; 
  greyout(binbtn) ; 
  selected[0] = -1 ; 
}
/* ------------------------------- getbtnpos -------------------------------- */

function getbtnpos(btnno)
{ var bounds=map.getBounds(),sw,ne,lat,lon,lam,b=btnno-2,h ;
  if(indexmode==0) { b = btnno ; h = 96 ; }
  else { h = 64 ; if(b==-2) b = 0 ; else if(b<0) b = 1 ; }
  sw = bounds.getSouthWest() ; 
  ne = bounds.getNorthEast() ; 
  lam = 52.0 / window.innerHeight ; 
  lat = lam*ne.lat() + (1-lam)*sw.lat() ; 
  lam = 0.5 + (b*32-h)/window.innerWidth ;
  lon = lam*ne.lng() + (1-lam)*sw.lng() ;
  return new google.maps.LatLng(lat,lon) ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- walkto --------------------------------- */

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

function walkto(s0,s1,flag) 
{ var datum = segments[s0].data[s1] , pos = datum.pos ; 
  if(flag==undefined) flag = 0  ;
  selected = [ s0,s1 ] ;
  if(indexmode) return highlight() ; 
  map.panToBounds(new google.maps.LatLngBounds(pos,pos)) ; 
  drawsel([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  ---------------------------- */

var flagged = 0 ; 
function keystroke(e)
{ if(infowindow.type=='addload') return ; 
  var s0=selected[0],s1=selected[1],slast,flag,canbin=binbtn.active ;

  if(e.keyCode==17||e.keyCode==224||e.keyCode==91||e.keyCode==93) return ; 
  // [control] + 3 encodings of [command]

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

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

  if(indexmode) 
  { 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(flagged==0) 
    { alert('Use the space bar to make waypoints draggable') ; flagged = 1 ; }
    return ; 
  } 

  if(e.keyCode==32) // space 
  { e.preventDefault() ; 
    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)
    { if( indexmode==0 && (segments.length>1||segments[0].data.length>1) ) 
        wpdel() ;
    }
    else if(canbin&&indexmode==0) discard() ; 
    else if(canbin) { selected[0] = s0 ; discard() ; selected[0] = -1 ; }
    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(map!=null) map.setOptions({draggableCursor:val?'crosshair':'default'}) ;
  if(val==0&&indexmode==0) getalts(segments,200,drawprofile) ;
}
/* --------------------- 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(indexmode) 
  { segments[i].colour = colours[i] ;
    segments[i].route.setOptions({strokeColor:colours[i]}) ; 
  }
  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) ; 
}
function drawprofile() { drawpro(pro,segments,selected) ; } 

/* ----------------------------- draw segments ------------------------------ */

function draw(i,width)
{ var colour,poly ; 
  if(indexmode) 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(indexmode||i<0||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(indexmode||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 ;
  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(selection)
{ if(selection!=undefined) selected = selection ;
  var ind,clen,s0=selected[0],s1=selected[1],pos=segments[s0].data[s1].pos ;
  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) blackout(binbtn) ; else greyout(binbtn) ;  
  if(s1!=0) blackout(scissorsbtn) ; else greyout(scissorsbtn) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*                FUNCTIONS TO GENERATE THE INITIAL MAP                       */
/* -------------------------------------------------------------------------- */

function checklostedits(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)
} 
/* -------------------------------------------------------------------------- */

function genpage(response,trackuri) 
{ var div,xmldoc ;
  if(listuri!=null) getlist(listuri,'uri') ;
  imginfo = { } ; 
  imgdiv = null ; 

  if(window.loaded) window.addEventListener("beforeunload",checklostedits) ; 
  else window.onload = function() 
  { window.addEventListener("beforeunload",checklostedits) ; } ;
  window.onfocus = function() { shiftkey(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(response==null)
  { div = filedialogue(0) ;
    div.setAttribute('style','margin:4px') ; 
    mapdiv.appendChild(div) ; 
    div = blurbdiv(resuri) ;
    div.setAttribute('style','font-family:helvetica;padding:0 4px;'+
                           'border-top:solid 1px silver;padding-top:4px;'+
                           'border-bottom:solid 1px silver;padding-bottom:2px'); 
    mapdiv.appendChild(div) ; 
    div = helpdiv(resuri,-1) ; 
    div.setAttribute('style','font-family:helvetica;margin:4px;font-size:90%') ;
    mapdiv.appendChild(div) ; 
  }
  else render(response,trackuri,0,'uri') ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------- getlist --------------------------------- */

function getlist(uri,imgtype) 
{ if(uri.substring(uri.length-4).toLowerCase()!='.xml')
  { alert(+uri+" is not an XML file") ; 
    imginfo = { status:'failed' } ; 
    return ; 
  }

  var xhttp = new XMLHttpRequest(),xmldoc,i,r ; 
  imginfo = { status:'waiting' , type:imgtype , uri:uri } ; 

  xhttp.onreadystatechange = function() 
  { if(xhttp.readyState==4)
    { if(xhttp.status!=200)
      { alert("Unable to read "+uri+": error code "+xhttp.status) ; 
        imginfo = { status:'failed' } ; 
        return ; 
      }
      xmldoc = parser.parseFromString(xhttp.responseText,"application/xml") ;
      imginfo = getphotolist(xmldoc,uri) ; 
      imginfo.status = 'ready' ; 
      imginfo.type = imgtype ;
      imginfo.uri = uri ; 
    }
  }
  xhttp.open("GET",fileserver+'?'+uri,true) ;
  xhttp.send() ;
}
/* ----------------------------- file dialogue ------------------------------ */

function filedialogue(overwrite)
{ var div = document.createElement('div') ; 
  var input = document.createElement('input') ; 
  var para = document.createElement('p') ; 
  para.setAttribute('style','font-family:helvetica;margin:0') ; 
  para.appendChild(document.createTextNode
                             ('Select TCX/GPX/FIT file: ')) ; 
  input.setAttribute('type','file') ; 
  input.setAttribute('accept','.tcx,.gpx,.fit') ; 
  input.addEventListener('change',function(e)
  { var filename = input.files[0].name , reader = new FileReader() ;
    if(filename.substring(filename.length-4).toLowerCase()=='.fit')
    { reader.onload = function(e) 
      { render(reader.result,filename,overwrite,'file') ; } 
      reader.readAsArrayBuffer(input.files[0]) ;	
    }
    else
    { reader.onload = function(e) 
      { render(reader.result,filename,overwrite,'file') ; } 
      reader.readAsText(input.files[0]) ;	
    }
  } ) ;
  para.appendChild(input) ; 
  div.appendChild(para) ; 

  para = document.createElement('p') ; 
  para.setAttribute('style','font-family:helvetica;margin:4 0 0') ; 
  para.appendChild(document.createTextNode ('or load from web: ')) ; 
  var input2 = document.createElement('input') ;
  input2.setAttribute('type','text') ; 
  input2.setAttribute('value',uristem) ; 
  input2.setAttribute('style','width:400px;height:24px') ;
  input2.onkeyup = function(e)
  { if(e.keyCode==13||e.which==13) 
    { trackuri = this.value ; 
      readuri(function(r) { render(r,trackuri,overwrite,'uri') ; }) ;
    }
  } ;
  para.appendChild(input2) ; 
  div.appendChild(para) ; 
  return div ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------- set up the map and buttons ------------------------- */

function render(response,filename,overwrite,origin) 
{ var i,t,opts,centre,lat,lon,minlon,maxlon,minlat,maxlat,newseg,s0,d,title ;
  var segno,ndata,xmldoc,data,uri,m=(indexmode>0?null:map) ; 
  // purge body of everything except mapdiv
  if(mapparent==null||body==null) 
    mapparent = body = document.getElementsByTagName("body")[0] ;
  if(map==null) for(i=body.childNodes.length-1;i>=0;i--)
    if(body.childNodes[i]!=mapdiv) body.removeChild(body.childNodes[i]) ;

  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).toLowerCase()!='.fit') 
    xmldoc = parser.parseFromString(response,"application/xml") ;
  if(filename.substring(i-4).toLowerCase()=='.tcx') 
    newseg = readtcx(xmldoc,filename) ; 
  else if(filename.substring(i-4).toLowerCase()=='.gpx') 
    newseg = readgpx(xmldoc,filename) ; 
  else if(filename.substring(i-4).toLowerCase()=='.fit') 
  { newseg = readfit(response) ; 
    title = filename.substring(0,i-4) ;
    if(origin=='uri')
    { i = title.lastIndexOf('/') ; if(i>=0) title = title.substring(i+1) ; }
    newseg.props[0].title = newseg.props[0].srcid = title ; 
  }
  else { alert(filename+' is not TCX/GPX/FIT') ; throw '' ; }

  if(segments.length==0||overwrite) routeprops = new propstype() ;

  if(newseg.segments.length==0||newseg.segments[0].length==0) 
  { alert('no data returned') ; return ; }
  if(newseg.segments.length>1&&setbtn!=null&&overwrite==0)
  { 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&&newseg.props[i].list!=undefined)
  { if(imginfo.uri==null||imginfo.uri==undefined)
    { routeprops.list = newseg.props[i].list ; 
      getlist(newseg.props[i].list,'tcx') ; 
    }
    else if(newseg.props[i].list!=imginfo.uri)
      abend('inconsistent photo lists: ' + imginfo.uri + '/' + 
            newseg.props[i].list) ; 
  }

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

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

  if(newseg.segments.length>1) { indexmode = 1 ; routeprops.stars = -1 ; } 
  if(indexmode) colours = gencolours(s0+newseg.segments.length) ; 

  if(filename.substring(filename.length-4).toLowerCase()=='.tcx')
    if(routeprops.title==null&&newseg.title!=null) 
      setdomtitle(routeprops.title=newseg.title) ; 

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

  if(filename.substring(filename.length-4).toLowerCase()!='.fit')
    if(routeprops.filename==null)
  { title = filename.substring(0,filename.length-4) ;
    if(origin=='uri')
    { i = title.lastIndexOf('/') ; if(i>=0) title = title.substring(i+1) ; }
    routeprops.filename = title ; 
  }

  data = segments[segments.length-1].data ;
  if(!newseg.props[0].optim.already&&!indexmode) 
  { ndata = optimaction(data,defparms,0) ; 
    actions[nactions++] = [ 'optimise' , segments.length-1 , defparms ] ; 
  }
  else if(indexmode>0&&s0>0) 
  { getstats(data,segments[segments.length-1].props) ;
    if(filename!=null)
    { uri = document.URL ;  
      i = uri.indexOf('?') ; 
      if(i>=0) uri = uri.substring(0,i) ; 
      if(origin=='uri') uri = reluri(uri,'?track='+filename) ; 
      else uri = reluri(uri,'?track=$FILE$/'+filename) ; 
      segments[segments.length-1].props.tlink = uri ; 
    }
    for(t=0;t<data.length;t++) data[t] = new datatype(data[t].pos,0) ; 
    ndata = optimaction(data,indparms,1) ;
    actions[nactions++] = [ 'optimise' , segments.length-1 , indparms ] ; 
  }
  else ndata = null ; 
  if(ndata!=null) segments[segments.length-1].data = ndata ; 

  if(indexmode) 
  { for(d='',i=0;i<segments.length;i++) if(segments[i].props.title!=null)
    { if(d=='') d = 'GPS tracks: ' ; else d += ' | ' ;
      d += newseg.props[i].title ; 
    }
    if(d!=null) routeprops.desc = d ; 
  }
  else 
  { for(i=0;i<newseg.segments.length&&routeprops.desc==null;i++)
      routeprops.desc = newseg.props[i].desc ; 
    getalts(segments,100,drawprofile) ; 
  }
  if(s0==0&&routeprops.desc!=null) setdomdesc(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: indexmode?
                google.maps.MapTypeId.ROADMAP:google.maps.MapTypeId.TERRAIN,
             disableDoubleClickZoom: true,
             fullscreenControl:true,
             fullscreenControlOptions: 
               {position:google.maps.ControlPosition.BOTTOM_RIGHT},
             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) ;
    map.setOptions({draggable:true, draggableCursor:'default'}) ;
    clickhandle = google.maps.event.addListener(map,"click",selpoint) ;

    // set up buttons
    setbtn = genbutton('settings') ;
    if(indexmode==0)  scissorsbtn = genbutton('scissors') ;
    binbtn = genbutton('bin',indexmode) ;
    if(indexmode==0)  penbtn = genbutton('pen') ;
    undobtn = genbutton('undo') ;
    redobtn = genbutton('redo') ;
    dlbtn = genbutton('dl') ;
    if(indexmode==0) window.addEventListener('resize',resizer) ; 
  }

  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(ndata!=null) donesomething() ;     // specifically, done some optimisation
  else actions.length = nactions ; // load with no optimisation hence no undo

  if(indexmode==0)
  { if(s0==0) selected = [0,0] ; 
    pro = new profiletype(map) ; 
    mapparent.appendChild(pro.prodiv) ; 
    mapparent.appendChild(pro.curdiv) ; 
    drawprofile() ;
  }
  else { if(s0==1&&segments.length>1) blackout(dlbtn) ; selected = [-1,-1] ; }
  for(segno=s0;segno<segments.length;segno++)
  { draw(segno) ; connect(segno-1) ; }
  connect(segments.length-1) ; 

  if(s0>0&&indexmode==0) greyout(dlbtn) ; else if(indexmode==0) drawsel() ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* ------------------------------- retitle ---------------------------------- */

function retitle(opt,segno) 
{ var newval , oldval , msg , field = (opt=='description'?'desc':'title') ; 

  if(segno!=null&&segno!=undefined) oldval = segments[segno].props[field] ;
  else { oldval = routeprops[field] ; segno = null ; }
  infowindow.close() ; 

  if(oldval==null) msg = 'Add ' + opt ; else msg = 'Modify ' + opt + ":" ;
  newval = window.prompt(msg,oldval==null?'':oldval) ;
  if(newval==null||newval==oldval) return ; 
  if(newval=='') newval = null ; 
  if(opt=='description') 
  { if(segno==null) routeprops.desc = newval ; 
    else segments[segno].props.desc = newval ; 
  }
  else 
  { if(segno==null) setdomtitle(routeprops.title=newval) ; 
    else segments[segno].props.title = newval ; 
  }

  actions[nactions++] = [ 'edit'+opt , oldval , newval , segno ] ; 
  donesomething() ;
  if(indexmode&&segno==null) cogwheel() ; 
  else if(indexmode>0) { selected[0] = segno ; highlight() ; }
  else routeinfo() ; 
}
/* ------------------------------- restars ---------------------------------- */

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

function genbutton(name,indexmode)
{ var u,v,w,b,g,k,h,div=document.createElement('div'),act,gtitle=null,ktitle ;
  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 = 2 ; 
    ktitle = 'Split segment at current waypoint' ; 
    gtitle = 'Split segment [disabled at first waypoint in a segment]' ;
  }
  else if(name=='bin') 
  { h = discard ; 
    div.index = 3 ; 
    if(indexmode==0)
    { ktitle = 'Delete current segment' ; 
      gtitle = 'Delete segment [disabled when the route is a single segment]' ;
    }
    else 
    { ktitle = 'Delete route' ; 
      gtitle = 'Delete route [disabled when the index is a single route]' ;
    }
  }
  else if(name=='pen') 
  { h = labelprompt ; 
    div.index = 4 ; 
    ktitle = 'Add a course point [or photo if right-clicked]' ; 
  }
  else if(name=='undo') 
  { h = undo ; 
    div.index = 5 ; 
    ktitle = 'Undo latest edit' ; 
    gtitle = 'Undo [no edits performed]' ;
  }
  else if(name=='redo') 
  { h = redo ; 
    div.index = 6 ; 
    ktitle = 'Redo latest undone edit' ; 
    gtitle = 'Redo [no edits undone]' ;
  }
  else if(name=='dl') 
  { h = function() { dl(0) ; }  ; 
    div.index = 7 ; 
    ktitle = 'Download route' ; 
    gtitle = 'Download [disabled until segments are combined]' ;
  }
  else if(name=='settings') 
  { h = cogwheel ; 
    div.index = 1 ; 
    ktitle = 'Control menu' ; 
  }

  if(gtitle==null) gtitle = ktitle ; 
  g = greybtn(resuri,name) ;
  k = blackbtn(resuri,name) ;
  if(name=='dl'||name=='settings') { b = buttonimg(k) ; u.title = ktitle ; }
  else { b = buttonimg(g) ; u.title = gtitle ; }
  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 , 
           greytitle:gtitle, blacktitle:ktitle , handler:h } ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

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*/
/* ---------------------------------- dl  ----------------------------------- */

function dl(opt) 
{ var str,i,npix,s0,s1,filename,legend,callfunc,extn,ooo,time,tlast,sep,maxsep ;
  infowindow.close() ; 
  if(opt==undefined) opt = 0 ; 
  if(opt==0&&indexmode<1) getalts(segments,1,drawprofile) ; 

  // filename
  if(routeprops.filename!=null&&opt==0) filename = routeprops.filename ;
  else
  { i = routeprops.title.indexOf(' ') ;
    while(i>0&&routeprops.title.charAt(i-1)=='.') i -= 1 ; 
    if(i<=0) filename = routeprops.title ; 
    else filename = routeprops.title.substring(0,i) ;
  }
  if(filename==undefined||filename==''||filename==null) filename = 'Untitled' ; 

  // 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) ; 
    }
  }

  div = document.createElement('div') ; 
  if(opt) { indexdl(filename+'.tcx',indparms) ; return ; }
  else if(indexmode>0)
  { filename += '.tcx' ;
    str = 'indexdl("' + filename + '",null)' ;
    div.appendChild(genclickable(str,'Download index as '+filename,'br')) ;
    infowindow.open(div,getbtnpos(6),'download') ; 
    return ;
  }

  for(extn='.tcx',i=0;i<2;i++,extn='.gpx')
  { legend = 'Download as ' + filename + extn ;
    callfunc = 'confirmeddl("'+ filename + extn + '")' ;
    div.appendChild(genclickable(callfunc,legend,'br')) ;
  }
  for(tlast=null,maxsep=ooo=i=0;i<segments[0].data.length;i++)
  { time = segments[0].data[i].t ;
    if(tlast!=null&&time!=null&&time<tlast) ooo = 1 ; // out of order
    if(time!=null) tlast = time ;
    if(i)
    { sep = dist(segments[0].data[i-1].pos,segments[0].data[i].pos) ;
      if(sep>maxsep) maxsep = sep ; 
    }
  }
  if(maxsep>100)
  { legend = 'Interpolate extra points' ;
    div.appendChild(genclickable('extrapts(1)',legend,'br')) ;
    legend = 'Warning: largest gap between waypoints ('+maxsep.toFixed()+
             'm) is >100m:' ; 
    div.appendChild(genspan(legend,'br')) ;
    legend = 'This will cause navigation problems on Garmin devices.' ; 
    div.appendChild(genspan(legend,'br')) ;
    legend = 'Interpolate extra points to fix or download anyway.' ; 
    div.appendChild(genspan(legend,'br')) ;
  }

  if(ooo)
  { div.appendChild(genspan('Note: your times are out of sequence and','br')) ;
    div.appendChild(genspan('will not be retained','br')) ;
  }
  infowindow.open(div,getbtnpos(opt?0:6),'download') ; 
}
function canceldl() { infowindow.close() ; }

function indexdl(filename,parms)
{ var str = writeindex(segments,routeprops.title,routeprops.list,parms) ;  
  saveAs(new Blob([str],{type: "text/plain;charset=utf-8"}),filename) ;
}

function confirmeddl(filename)
{ var nnull,i,div,legend ; 
  pendingdl = filename ; 
  for(nnull=i=0;nnull==0&&i<segments[0].data.length;i++) 
    if(segments[0].data[i].h==null) nnull = 1 ; 
  if(nnull==0) { reconfirmeddl() ; return ; } 

  div = document.createElement('div') ; 
  legend = 'Waiting for missing altitudes from Google Elevation Service.' ;
  div.appendChild(genspan(legend,'br')) ;
  div.appendChild(genspan('If you don’t want to wait you can either ')) ;
  div.appendChild(genclickable('canceldl()','cancel','br')) ;
  div.appendChild(genspan('or ')) ;
  legend = 'use interpolated altitudes' ;
  div.appendChild(genclickable('semiconfirmeddl()',legend)) ;
  div.appendChild(genspan('.')) ;
  infowindow.open(div,getbtnpos(6),'download') ; 
  getalts(segments,1,drawprofile,reconfirmeddl) ;
}
function semiconfirmeddl() // turn off callback to reconfirmeddl
{ getalts(null,null,null,null) ; reconfirmeddl() ; }

function reconfirmeddl()
{ var i,str,filename=pendingdl,mode,len=filename.length ;
  if(filename==null) return ;
  infowindow.close() ; 
  mode = filename.substring(len-3) ;
  // 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 ;
  }
  
  if((str=writegps(routeprops,segments[0].data,mode))!=null) 
  { unsavedchanges = [] ; 
    saveAs(new Blob([str],{type: "text/plain;charset=utf-8"}),filename) ;
  }
}
/* -------------------------------------------------------------------------- */

• optimaction     • optimprompt     • cogwheel     • calwork     • manualcal     • googlecalwork     • googlecal     • help     • wpdelwork     • wpdel     • revsegwork     • revseg     • insert     • inswp     • draggit     • undraggit     • seginfo     • deltimes     • extrapts     • routeinfo     • combine1     • combinework     • combine     • uncombine     • wpinfo     • setalt     • labelprompt     • labelcycle     • photoprompt     • photoedit     • advance     • retreat     • prev     • backtogps     • next     • display     • dodisplay     • imgwalk     • phinfo     • snipwork     • snip     • xferwpwork     • xferwp     • binwork     • discard     • swapsegwork     • swapseg     • actiontype     • done     • donesomething     • undo     • confirmedundo     • move     • redo     • confirmedredo

/* -------------------------------------------------------------------------- */
/*                             OPTIMISATION                                   */
/* -------------------------------------------------------------------------- */

function optimaction(data,parms,force)
{ var result = optimise(data,parms) , loadno ; 
  var ndel = data.length - result.length ; 
  for(loadno=nactions-1;loadno>=0&&actions[loadno][0]!='load';loadno--) ; 
  if(loadno<0||(force==0&&ndel<data.length/2)||ndel==0) return null ; 

  actions[loadno][3].optim.origlen = data.length ; 
  actions[loadno][3].optim.ndel = ndel ; 
  actions[loadno][3].optim.parms = { tol: parms.tol , 
                                     maxsep: parms.maxsep , 
                                     wppenalty: parms.wppenalty , 
                                     vweight: parms.vweight 
                                   } ; 
  return result ; 
}
/* -------------------------------------------------------------------------- */

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,ndata ; 
  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 ; 
  }

  ndata = optimaction(segments[segments.length-1].data,parms,1) ;
  if(ndata!=null) 
  { segments[segments.length-1].data = ndata ; 
    actions[nactions++] = [ 'optimise' , segments.length-1 , parms ] ; 
    donesomething() ; 
    draw(segments.length-1) ; 
  } 
  routeinfo() ; 
}  
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function cogwheel()
{ infowindow.close() ;
  var d = cogwheelmenu(indexmode,dragging,routeprops.index) ;
  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 ; 
  drawprofile() ; 
}
/* ------------------------------ 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]) ; 
}  
/* ---------------------------- googlecalwork ------------------------------ */

function googlecalwork(s0)
{ var i,s1 ; 
  for(s1=0;s1<segments[s0].data.length;s1++) segments[s0].data[s1].h = null ; 
  getalts(segments,1,drawprofile) ;
}
/* ------------------------------ manualcal --------------------------------- */

function googlecal()
{ infowindow.close() ; 
  var i,s0=selected[0],len=segments[s0].data.length,alt=new Array(len) ;
  for(i=0;i<len;i++) alt[i] = segments[s0].data[i].h ; 
  googlecalwork(s0) ; 
  done(['googlecal',s0,alt]) ; 
}  
/* --------------------------------- help ----------------------------------- */

function help() 
{ infowindow.close() ; 
  infowindow.open(helpdiv(resuri,indexmode),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() ; 
  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() ; 
}
/* -------------------------------------------------------------------------- */

function revseg()
{ infowindow.close() ; 
  revsegwork(selected[0]) ; 
  done(['revseg',selected[0]]) ; 
}
/* -------------------------------------------------------------------------- */
/*    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(segments,100,drawprofile) ; 

  sel.marker.setMap(null) ; 
  sel.marker = null ; // force a redraw
  drawsel() ; 
  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(segments,1,drawprofile) ; 
  done(task) ; 
  if(opt==1) dl(0) ; else routeinfo() ; 
}
/* -------------------------------------------------------------------------- */
/*       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)
{ undraw(sb) ; 
  disconnect(sb-1) ; 
  if(selected[0]==sb) selected = [ sa , selected[1]+segments[sa].data.length ] ; 
  segments[sa].data = segments[sa].data.concat(segments[sb].data) ; 
}
function combinework()
{ var s0 ;
  for(s0=1;s0<segments.length;s0++) combine1(0,s0) ;
  segments.length = 1 ; 
  redraw(0) ; 
  drawprofile() ; 
  drawsel() ; 
  blackout(dlbtn) ; 
}
/* -------------------------------------------------------------------------- */

function combine()
{ var i , task = [ 'combine' , segments.length ] ;
  infowindow.close() ; 
  for(i=0;i<segments.length;i++) 
    task.push(segments[i].data.length,segments[i].props) ; 
  combinework() ; 
  done(task) ; 
}  
/* -------------------------------------------------------------------------- */

function uncombine(task)
{ var i,llen,nlen ; 

  segments.length = task[1] ;
  for(flag=0,s0=task[1]-1,i=task.length-2;i>=4;i-=2,s0--)
  { llen = segments[0].data.length ;
    segments[s0] = new genseg(segments[0].data.slice(llen-task[i]),task[i+1]) ; 
    if(flag==0&&selected[1]>=segments[0].data.length)
    { selected = [ s0 , selected[1]-segments[0].data.length ] ; flag = 1 ; } 
    draw(s0) ; 
    connect(s0) ; 
  }
  segments[0].data.length = task[2] ; 
  for(llen=s0=0;s0<segments.length;llen=nlen,s0++)
  { nlen = llen + segments[s0].data.length ;
    if(selected[1]>=llen&&selected[1]<nlen) 
    { selected = [ s0 , selected[1]-llen ] ; break ; }
  }
  redraw(0) ; 
  drawprofile() ; 
  drawsel() ; 
  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]) ; 
  drawprofile() ; 
  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 ; } 

  datum.setlabel(type,caption) ; 
  datum.setlabelmap(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]) ; 
    datum.setphotomap(map,selpoint) ;
    walkto(s0,s1) ;
  }
  else if(flag==1) wpinfo() ; 
  else if(flag==2) seginfo() ; 
  else if(flag==3) highlight() ; 
  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) ; 
  mapparent.removeChild(imgdiv) ; 
  imgdiv = null ; 
  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) ; 
  mapparent.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] ; 

  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 ; 
  draw(s0) ;
  connect(s0) ; 
  draw(s0+1) ; 
  for(i=s0+2;i<segments.length;i++) recolour(i) ;
  selected = [s0+1,0] ; 
  drawprofile() ;
  drawsel() ; 
  greyout(dlbtn) ; 
}
function snip()
{ var s0=selected[0],s1=selected[1] ; 
  infowindow.close() ; 
  done(['snip',s0,s1]) ; 
  snipwork(s0,s1) ; 
}
/* ----------------------- xferwp: transfer waypoint  ----------------------- */

function xferwpwork(s0,s1)
{ undraw(s0) ; 
  if(s1==0)
  { undraw(s0-1) ; 
    disconnect(s0-1) ; 
    segments[s0-1].data.push(segments[s0].data[0]) ; 
    segments[s0].data = segments[s0].data.slice(1,segments[s0].data.length) ; 
    draw(s0-1) ; 
    connect(s0-1) ; 
    if(selected[0]==s0&&selected[1]==s1)
    { selected[0] -= 1 ; selected[1] = segments[s0-1].data.length-1 ; }
    else if(selected[0]==s0) selected[1] -= 1 ; 
  }
  else
  { undraw(s0+1) ; 
    disconnect(s0) ; 
    segments[s0+1].data.unshift(segments[s0].data[segments[s0].data.length-1]) ; 
    segments[s0].data.length -= 1 ; 
    draw(s0+1) ; 
    connect(s0) ; 
    if(selected[0]==s0&&selected[1]==segments[s0].data.length)
    { selected[0] += 1 ; selected[1] = 0 ; }
    else if(selected[0]==s0) selected[1] += 1 ; 
  }
  draw(s0) ; 
  drawprofile() ; 
}
function xferwp()
{ var flag = infowindow.close() ; 
  done(['xferwp',selected[0],selected[1]]) ; 
  xferwpwork(selected[0],selected[1]) ; 
  if(flag=='highlight') flag = 3 ; 
  else if(flag=='seginfo') flag = 2 ; 
  else if(flag=='wpinfo') flag = 1 ; 
  else flag = 0 ; 
  walkto(selected[0],selected[1],flag) ;
}
/* ------------------------ 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] -= 1 ; selected[1] = segments[selected[0]].data.length - 1 ; }

  if(indexmode==0) 
  { drawprofile() ; drawsel() ; if(segments.length==1) blackout(dlbtn) ; }
  else if(segments.length==1) greyout(dlbtn) ; 
}
function discard()
{ var s0=selected[0] ; 
  infowindow.close() ;  
  if(s0>=0) { done(['bin',s0,segments[s0],indexmode]) ; binwork(s0) ; }
}
/* ---------------------- swapseg: swap two segments  ----------------------- */

function swapsegwork(s0)
{ var i , temp = segments[s0+1] ; 
  segments[s0+1] = segments[s0] ;
  segments[s0] = temp ; 
  for(i=s0-1;i<s0+2;i++) { reconnect(i) ; if(i>=s0) recolour(i) ; }
  drawprofile() ;
}
function swapseg(s0)
{ var flag = infowindow.close() ; 
  done(['swapseg',s0]) ; 
  swapsegwork(s0) ; 
  if(selected[0]==s0) selected[0] += 1 ; 
  else if(selected[0]==s0+1) selected[0] -= 1 ; 
  if(flag=='highlight') flag = 3 ; 
  else if(flag=='seginfo') flag = 2 ; 
  else if(flag=='wpinfo') flag = 1 ; 
  else flag = 0 ; 
  walkto(selected[0],selected[1],flag) ;
}
/* -------------------------------------------------------------------------- */

function actiontype(x)
{ if(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 = actionname(actions[nactions-1]) ;
  if(opts=='editdescription'&&actions[nactions-1][3]!=null)
    opts = 'edit ' + segments[actions[nactions-1][3]].props.title + 
           ' description' ; 
  else if(opts=='edittitle'&&actions[nactions-1][3]!=null)
    opts = 'edit route description' ; 
  opts = 'Undo ' + opts ;
  infowindow.open(genclickable('confirmedundo()',opts),getbtnpos(4),'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'&&action!='swapseg') 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 ; 
    if(indexmode==0) { drawsel() ; greyout(dlbtn) ; }
    else blackout(dlbtn) ; 
  }
  else if(action=='snip') // undo snip
  { selected = [ s0 , segments[s0].data.length-1 ] ; 
    undraw(s0) ; 
    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() ; 
  } 
  else if(action=='xferwp') // undo transfer waypoint
  { if(s1==0) xferwpwork(s0-1,segments[s0-1].data.length-1) ; 
    else xferwpwork(s0+1,0) ;
  }
  else if(action=='editlabel')  // undo create/edit/delete label
  { segments[s0].data[s1].setlabel(actions[ano][5],actions[ano][3]) ;
    segments[s0].data[s1].setlabelmap(map,selpoint,labelcycle) ;
  }
  else if(action=='edittitle'||action=='editindex title') 
  { if(actions[ano][3]==null) setdomtitle(routeprops.title=s0) ; 
    else segments[actions[ano][3]].props.title = s0 ; 
  }
  else if(action=='editdescription') 
  { if(actions[ano][3]==null) routeprops.desc = s0 ; 
    else segments[actions[ano][3]].props.desc = 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([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=='googlecal') for(i=0;i<segments[s0].data.length;i++)
    segments[s0].data[i].h = s1[i] ;
  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=='swapseg') swapsegwork(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[ano][3].optim.ndel = 0 ; 
    redraw(s0) ;
    drawsel([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]]) ;
      segments[s0].data[s1].setphotomap(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==0||actions[nactions-1][0]=='load') greyout(undobtn) ; 
  blackout(redobtn) ; 
  if(actiontype(actions[nactions][0])!=0&&unsavedchanges.length>0)
    unsavedchanges.length -= 1 ;  ;
  if(action=='optimise'||action=='dltimes'||action=='stars') routeinfo() ; 
  else if(action=='editphoto'||action=='editlabel') walkto(s0,s1) ;
  else if(action=='editdescription')
  { if(actions[ano][3]==null) routeinfo() ; 
    else { selected[0] = actions[ano][3] ; highlight() ; }
  }
}
/* --------------------------------- move ----------------------------------- */

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

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

function redo()
{ infowindow.close() ;  
  var opts = actionname(actions[nactions]) ;
  if(opts=='editdescription'&&actions[nactions][3]!=null)
    opts = 'edit ' + segments[actions[nactions][3]].props.title + 
           ' description' ; 
  else if(opts=='edittitle'&&actions[nactions][3]!=null)
    opts = 'edit route description' ; 
  opts = 'Redo ' + opts ;
  infowindow.open(genclickable('confirmedredo()',opts),getbtnpos(5),'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'&&action!='swapseg') s1 = actions[nactions][2] ;
  infowindow.close() ; 

  if(action=='bin') binwork(s0) ; 
  else if(action=='snip') snipwork(s0,s1) ; 
  else if(action=='xferwp') xferwpwork(s0,s1) ; 
  else if(action=='editlabel') // redo create/edit/delete label
  { segments[s0].data[s1].setlabel(actions[nactions][6],actions[nactions][4]) ;
    segments[s0].data[s1].setlabelmap(map,selpoint,labelcyle) ; 
  }
  else if(action=='edittitle'||action=='editindex title') 
  { if(actions[nactions][3]==null) setdomtitle(routeprops.title=s1) ; 
    else segments[actions[nactions][3]].props.title = s1 ; 
  }
  else if(action=='editdescription') 
  { if(actions[nactions][3]==null) routeprops.desc = s1 ; 
    else segments[actions[nactions][3]].props.desc = 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=='googlecal') googlecalwork(s0) ; 
  else if(action=='setalt') segments[s0].data[s1].h = actions[nactions][4] ;
  else if(action=='combine') combinework() ; 
  else if(action=='revseg') revsegwork(s0) ; 
  else if(action=='swapseg') swapsegwork(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]) ; 
    for(ano=nactions-1;ano>=0&&actions[ano][0]!='load';ano--) ;
    actions[ano][3].optim.ndel = segments[s0].data.length - result.length ;
    segments[s0].data = result ; 
    redraw(s0) ;
    drawsel([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]) ;
      segments[s0].data[s1].setphotomap(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=='optimise'||action=='dltimes'||action=='stars') routeinfo() ; 
  else if(action=='editphoto'||action=='editlabel') walkto(s0,s1) ;
  else if(action=='editdescription')
  { if(actions[nactions-1][3]==null) routeinfo() ; 
    else { selected[0] = actions[nactions-1][3] ; highlight() ; }
  }
}
/* -------------------------------------------------------------------------- */

• 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     • doalts     • starsline     • createfunc     • phdiv     • actionname     • profiletype     • drawxcur     • drawpro     • toggleprofile     • point2LatLng

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

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 ; 
  var span,prose,lim,d ; 
  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.srcid!=null&&props.srcid!=props.title) 
    div.appendChild(textdiv('Source',props.srcid)) ;
  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)) ;

  div.setAttribute('style','margin-bottom:2px;'+
                   'border-bottom:solid 1px silver;padding-bottom:2px') ; 
  d = document.createElement('div') ;
  d.appendChild(div) ;
  d.appendChild(genclickable('manualcal()',
                'Add an offset to segment altitudes','br')) ; 
  d.appendChild(genclickable('googlecal()',
                'Ask Google to recalibrate segment altitudes','br')) ; 

  prose = 'Swap with preceding segment' ;
  if(segno==0) span = genspan(prose,'br') ;
  else span = genclickable('swapseg('+(segno-1)+')',prose,'br') ;
  d.appendChild(span) ;
  prose = 'Swap with following segment' ;
  if(segno==segments.length-1) span = genspan(prose,'br') ;
  else span = genclickable('swapseg('+segno+')',prose,'br') ;
  d.appendChild(span) ;
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function highdiv(segno,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) ;
  }

  div.appendChild(titlediv('Title',props.title,1,segno)) ; 
  if(props.stars!=null) div.appendChild(starsline(props.stars,0)) ;

  d = titlediv('Description',props.desc,1,segno) ;
  if(items.length>0&&props.desc&&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.date !=null) div.appendChild(textdiv('Date', props.date)) ; 

  if(props.tlink!=null)
  { nobr = document.createElement('nobr') ;
    a = document.createElement('a') ;
    a.setAttribute('style',
                   'cursor:pointer;color:#0000bd;text-decoration:none') ; 
    a.setAttribute('href',props.tlink) ; 
    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)  { return uri + 'grey' + name + '.png' ; }
function blackbtn(uri,name) { return uri + 'black' + 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,style)
{ var defstyle = 'cursor:pointer;color:#0000bd' ;
  if(style!=undefined&&style!=null) defstyle += ';' + style ; 
  var span = genspan(legend,bropt,defstyle) ; 
  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.createTextNode
    (' is used to display GPS tracks (including embedded photos),' +
     ' allowing them to be edited and saved back to disc.')) ;
  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,mode)
{ var div=document.createElement('div'),d,t,tr,td,a ; 
  if(mode==undefined) mode = 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(blackbtn(uri,'settings'),blackbtn(uri,'dl'))) ; 
  tr.appendChild(textcell('access to help menu and to various tools and ' + 
                          'functions /','download route')) ; 
  t.appendChild(tr) ; 

  tr = document.createElement('tr') ;
  if(mode==1)
  { tr.appendChild(buttoncell(blackbtn(uri,'bin'))) ; 
    tr.appendChild(textcell
    ('delete the currrent route (or use the [shift delete] '+
     'or [shift backspace] key)')) ;
  }
  else
  { 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,'undo'),blackbtn(uri,'redo'))) ; 
  tr.appendChild(textcell('undo/redo')) ;
  t.appendChild(tr) ; 

  if(mode<1)
  { tr = document.createElement('tr') ;
    tr.appendChild(buttoncell(blackbtn(uri,'pen'))) ; 
    tr.appendChild(textcell
      ('add a labelled coursepoint (or right-click for photo) '+
       'at the current position (1-10chars)',
       'click on flag to edit; right-click on flag 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') ;
  if(mode==1) 
    appendrow(td,
       '[shift del], [shift backspace] = delete route (=bin button).') ;
  else
  { appendrow(td,
              '\u2190/\u2192 move to the previous/next waypoint;') ;
    appendrow(td,
         '[shift \u2190]/[shift \u2192] move to the previous/next segment;') ;
    appendrow(td,'\u2193 centres the map on the current waypoint;') ;
    appendrow(td,
       '[space] makes the current waypoint draggable or terminates dragging;') ;
    appendrow(td,'[tab] inserts a draggable waypoint forwards;') ;
    appendrow(td,'[del], [backspace] = delete waypoint;') ;
    appendrow(td,
         '[shift del], [shift backspace] = delete segment (=bin button).') ;
  }
  tr.appendChild(td) ; 
  t.appendChild(tr) ; 

  if(mode<1)
  { 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,
           '[click] selects the waypoint closest to the cursor position;') ;
    appendrow(td,
           '[shift click] extends the current segment to 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(mode<0)
  { a = genlink('http://www.masterlyinactivity.com/routemaster/?track=routes/'+
                'capeverde/Caibros.tcx','Example track to experiment with') ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
    a = genlink('http://www.masterlyinactivity.com/routemaster/?track=routes/'+
                'capeverde/capeverde.tcx','Example of a route index') ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
  }

  a = genlink('http://www.masterlyinactivity.com/software/routemaster.html',
              'Full documentation (with list of recent changes)') ; 
  if(mode>=0) a.setAttribute('target','_blank') ;
  d.appendChild(a) ; 
  if(mode>=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(indexmode,dragopt,ov)
{ var d = document.createElement('div') ;
  // dragging
  if(dragopt) 
  { d.appendChild(genspan('Hit [space] when you\'ve finished dragging','br')) ;
    return d ;
  }

  if(indexmode)
  { d.appendChild(titlediv('Index title',routeprops.title,1)) ;
    if(indexmode==1) 
      d.appendChild(genclickable('addload(0)','Add route','hr')) ;
    else d.appendChild(genclickable('addload(0)','Add index or route','hr')) ;
    d.appendChild(genclickable('help()','Help','br')) ; 
    return d ; 
  }

  // route options
  d.appendChild(genclickable('routeinfo()','Route info','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')) ; 
  d.appendChild(genclickable('revseg()','Reverse segment','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')) ; 

  // help menu
  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,thumbind(imginfo.sizes)) ;
      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') ;
  var div = document.createElement('div') ; 
  underline(d) ;

  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 { x = -lng ; s += x.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).toString() ;
  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) 
  { grad = 100*Math.atan2(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')) ;
  }

  div.appendChild(d) ;
  if(segments.length>1) s = 'Segment '+s0+' p' ; else s = 'P' ;
  s += 'oint ' + s1 ; 
  div.appendChild(genspan(s,'br','font-size:80%')) ;

  if((s1==0&&s0>0)||(s1==segments[s0].data.length-1&&s0<segments.length-1))
    if(segments[s0].data.length>1)
      div.appendChild(genclickable('xferwp()','Transfer waypoint to segment '+
        (s1==0?s0-1:s0+1),'br','font-size:80%')) ;
  return div ; 
}
/* -------------------------------------------------------------------------- */

function titlediv(a,b,editable,segno)
{ var s,c,d=document.createElement('div') ; 
  if(segno==undefined) segno = null ; 

  if(editable) 
    s = '("' + a.toLowerCase() + (segno==null?'")':('",'+segno+')')) ;

  if(editable&&b==null)
  { d.appendChild(document.createTextNode('[')) ;
    a = a.toLowerCase() ; 
    s = '("' + a + (segno==null?'"':('",'+segno)) + ')' ;
    d.appendChild(genclickable('retitle'+s,'Add '+a.toLowerCase())) ;
    d.appendChild(document.createTextNode(']')) ;
    return d ; 
  }

  if(a!='Description'||b.length<50) 
    d.setAttribute('style','white-space:nowrap') ;
  c = document.createElement('b') ; 
  c.appendChild(document.createTextNode(a+': ')) ;
  d.appendChild(c) ;

  b = document.createTextNode(b) ;
  if(a=='Title'||a=='Index title')
  { c = document.createElement('b') ; c.appendChild(b) ; d.appendChild(c) ; }
  else d.appendChild(b) ;

  if(editable)
  { d.appendChild(document.createTextNode(' [')) ;
    d.appendChild(genclickable('retitle'+s,'Edit')) ;
    d.appendChild(document.createTextNode(']')) ;
  }
  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,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 ;
  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 ; 
      }
    }
  }

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

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

  if(rp.srcid!=null&&rp.srcid!=rp.title) 
    d.appendChild(titlediv('Source',rp.srcid)) ;

  // description
  d.appendChild(titlediv('Description',rp.desc,1)) ;

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

  // 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')) ;
    else d.appendChild(genspan('Optimise',null,'color:silver')) ; 
    d.appendChild(genspan(']','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 [')) ;
    if(altthresh==null||altthresh>1) 
      d.appendChild(genclickable('doalts()','Find altitudes',']br')) ;
    else d.appendChild(genspan('Awaiting results from altitude service',']br',
                                         'text-style:italic')) ;
  }

  // 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:') ;
  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)) ;
  tdiv.appendChild(ldiv) ; 

  ldiv = document.createElement('div') ; 
  ldiv.setAttribute('style','float:left;padding-left:2px') ; 
  ldivadd('km') ;
  ldivadd('m') ;
  ldivadd('m') ;
  tdiv.appendChild(ldiv) ; 

  d.appendChild(tdiv) ; 
  return d ; 
}
function doalts() { infowindow.close() ; getalts(segments,1,drawprofile) ; }

/* -------------------------------------------------------------------------- */
/*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 ; 
}
/* -------------------------------- actionname ------------------------------ */

function actionname(x)
{ var i,s ; 
  if(x[0]=='bin') 
  { if(x[3]==0) return 'delete segment' ; 
    else return 'delete ' + x[2].props.title ;
  }
  if(x[0]=='snip') return 'split segment' ; 
  if(x[0]=='xferwp') return 'transfer waypoint' ; 
  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'||x[0]=='editindex title') 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]=='googlecal') return 'ask Google to 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 ; } 
  if(x[0]=='swapseg') return 'swap segments' ; 
  alert('Unrecognised action: '+x[0]) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*          FUNCTIONS FOR COMPUTING & DISPLAYING THE ALTITUDE PROFILE         */
/* -------------------------------------------------------------------------- */

function profiletype(map)
{ var id,node ; 
  this.curhandle = this.m = null ; 
  this.map = map ; 
  this.sel = [0,0] ;
  this.ratio = window.devicePixelRatio ; 
  if(this.ratio==null||this.ratio==undefined) this.ratio = 1 ; 
  this.innerw = Math.floor(600*this.ratio) ; 
  this.innerh = Math.floor(180*this.ratio) ; 
  this.outerw = Math.floor(620*this.ratio) ; 
  this.outerh = Math.floor(200*this.ratio) ; 
  this.offs   = Math.floor((this.outerw-this.innerw)/2) ;
  this.radius = Math.floor(20*this.ratio) ; 
  this.active = 0 ; 
  this.prodiv = document.createElement('div') ;
  this.prodiv.setAttribute('id','prodiv') ;
  this.curdiv = document.createElement('div') ;
  this.curdiv.setAttribute('id','curdiv') ;
  this.curhandle = this.curdiv.addEventListener("click",toggleprofile) ;
  this.release = function()
  { for(id='prodiv';id!=null;id=(id=='prodiv'?'curdiv':null))
    { node = document.getElementById(id) ; node.parentNode.removeChild(node) ; }
    this.m = null ;
  }
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

function drawxcur(pro,sel)
{ var pos , i , linewid , inc , cx, cy , jlim , div , r=pro.radius ; 
  pos = pro.m.wp2pro[sel[0]][sel[1]] ;
  pro.sel = sel ; 
  for(div=pro.curdiv;div.childNodes.length>0;) 
    div.removeChild(div.childNodes[div.childNodes.length-1]) ;
  var c=document.createElement('canvas') , ctx=c.getContext("2d") ; 
  if(pro.active)
  { c.setAttribute('width',620) ; c.setAttribute('height',200) ; }
  else { c.setAttribute('width',42) ; c.setAttribute('height',42) ; }
  div.appendChild(c) ;
  ctx.scale(1/pro.ratio,1/pro.ratio) ;
  linewid = 2*Math.floor(pro.ratio+0.5) ; 

  // the cursor
  ctx.beginPath() ; 
  ctx.lineWidth = 1 ; 
  if(pro.active)
  { ctx.moveTo(pro.offs+pos+0.5,pro.offs) ; 
    ctx.lineTo(pro.offs+pos+0.5,pro.offs+pro.innerh) ; 
  }
  else 
  { jlim = Math.floor(Math.sqrt(r*r-(r-pos)*(r-pos))) ;
    ctx.moveTo(pos+linewid/2+0.5,linewid/2+r-jlim) ; 
    ctx.lineTo(pos+linewid/2+0.5,linewid/2+r+jlim) ; 
  }
  ctx.stroke() ; 

  // the circle of the 'x': first the filling
  ctx.beginPath() ; 
  cy = r + linewid/2 ;
  if(pro.active) 
  { cx = pro.outerw - r - linewid/2 ; 
    ctx.fillStyle = 'white' ; 
    ctx.arc(cx,cy,r,0,2*Math.PI,false) ; 
    ctx.fill() ;
  }
  else cx = r + linewid/2 ; 

  // then the outline (drawn separately so that the filling shouldn't overlap)
  ctx.beginPath() ; 
  ctx.strokeStyle = '#555' ; 
  ctx.lineWidth = linewid ; 
  ctx.arc(cx,cy,r,0,2*Math.PI,false) ;
  ctx.stroke() ; 
  if(pro.active==0) return ;

  // the two bars of the 'x'
  inc = r * Math.sqrt(2) / 2 ;
  ctx.beginPath() ; 
  ctx.moveTo(cx+inc,cy-inc) ; 
  ctx.lineTo(cx-inc,cy+inc) ; 
  ctx.stroke() ; 

  ctx.beginPath() ; 
  ctx.moveTo(cx+inc,cy+inc) ; 
  ctx.lineTo(cx-inc,cy-inc) ; 
  ctx.stroke() ; 
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */

var pcopy,scopy ;

function drawpro(pro,segments,sel)
{ var ctx,i,j,k,h,ind,step,imgdata,parity,d,cdash,ctxdash,linewid,c,maxi,maxj ;
  var jlim,r=pro.radius,div,divstyle,hind ;

  pcopy = pro ; 
  scopy = segments ; 
  for(div=pro.prodiv;div.childNodes.length>0;) 
    div.removeChild(div.childNodes[div.childNodes.length-1]) ;
  c = document.createElement('canvas') ;
  div.appendChild(c) ;

  divstyle = 'position:absolute;right:0;top:0;' ; 
  if(pro.active)
  { divstyle += 'height:200px;width:620px' ; 
    c.setAttribute('width',620) ; 
    c.setAttribute('height',200) ; 
    maxi = pro.innerw ;
    maxj = pro.innerh ;
  }
  else
  { divstyle += 'height:42px;width:42px' ;
    c.setAttribute('width',42) ; 
    c.setAttribute('height',42) ; 
    maxj = maxi = 2 * r ; 
  }

  div.setAttribute('style',divstyle) ; 
  ctx = c.getContext("2d") ; 
  ctx.scale(1/pro.ratio,1/pro.ratio) ;

  // pale grey background
  if(pro.active)
  { ctx.globalAlpha = 0.6 ; 
    ctx.fillStyle = 'lightgray' ;
    ctx.rect(0,0,pro.outerw,pro.outerh) ;
    ctx.lineWidth = 0 ; 
    ctx.fill() ; 
    ctx.font = Math.floor(0.5+10*pro.ratio)+"px Helvetica" ;
  }

  // draw a profile 
  pro.m = new profilemaptype(segments,maxi) ; 
  cdash = document.createElement('canvas') ;
  cdash.setAttribute('width',maxi) ; 
  cdash.setAttribute('height',maxj) ; 
  ctxdash = cdash.getContext("2d") ; 
  imgdata = ctxdash.createImageData(maxi,maxj) ;
  d = imgdata.data ; 

  for(i=0;i<maxi;i++) 
  { h = pro.m.h[i] ; 
    if(pro.active&&h==null) continue ;
    else if(h!=null) h = pro.m.hmin + 0.9*(pro.m.h[i]-pro.m.hmin) ; 
    if(h!=null) { h = maxj*(pro.m.hmax-h)/pro.m.hspan ; hind = Math.floor(h) ; }
    parity = pro.m.pro2wp[i][0] & 1 ;
    if(pro.active) for(j=hind;j<pro.innerh;j++)
    { ind = 4 * (i+maxi*j) ; 
      d[ind] = 255 ; 
      if(parity) d[ind+2] = d[ind+1] = 150 ; 
      if(j==hind) d[ind+3] = Math.floor(0.5+255*(hind+1-h)) ; // antialiasing
      else d[ind+3] = 255 ; 
    }
    else 
    { jlim = Math.floor(0.5+Math.sqrt(r*r-(i-r)*(i-r))) ;
      if(h==null) hind = r + jlim ; 
      j = r - jlim ; 
      if(j!=Math.floor(j)) j += 1 ; 
      for(;j<hind&&j<r+jlim;j++)
      { ind = 4 * (i+maxi*j) ; 
        d[ind+3] = d[ind+2] = d[ind+1] = d[ind] = 255 ; 
      }
      for(;j<r+jlim;j++)
      { ind = 4 * (i+maxi*j) ; 
        d[ind+3] = d[ind] = 255 ; 
        if(parity) k = 150 ; else k = 0 ; 
        if(j==hind) k = Math.floor(0.5+(h-hind)*255+(hind+1-h)*k) ;
        d[ind+2] = d[ind+1] = k ; 
      }
    }
  }
  ctxdash.putImageData(imgdata,0,0) ; 
  imgdata = null ; 
  if(pro.active==0) ctx.drawImage(cdash,1,1) ; 
  else ctx.drawImage(cdash,pro.offs,pro.offs) ;

  // lines
  if(pro.active) 
  { if(pro.m.hspan>2500) step = 1000 ; 
    else if(pro.m.hspan>1250) step = 500 ;
    else step = 100 ; 

    linewid = Math.floor(0.5+pro.ratio) ; 
    for(i=step*Math.floor(pro.m.hmin/step+1);i<pro.m.hmax;i+=step) 
    { y = pro.offs + pro.innerh*(pro.m.hmax-i)/pro.m.hspan ; 
      if(linewid&1) y = 0.5 + Math.floor(y) ; // nearest half-integer
      else y = Math.floor(0.5+y) ; 
      ctx.beginPath() ; 
      ctx.lineWidth = linewid ; 
      ctx.strokeStyle = '#555' ; 
      ctx.moveTo(pro.offs,y) ;
      ctx.lineTo(pro.innerw+pro.offs,y) ; 
      ctx.stroke() ; 
      ctx.strokeText(i,pro.innerw-pro.offs,y-2*linewid) ;
    }
  }

  // cursor
  pro.curdiv.setAttribute('style',divstyle) ; 
  drawxcur(pro,sel) ; 
}
function toggleprofile(e)
{ var flag , sel , pos = window.innerWidth - e.clientX ; 
  var pro=pcopy,segments=scopy ; 
  // pos is relative to rh edge of screen
  if((pos-20)*(pos-20)+(e.clientY-20)*(e.clientY-20)<400)
  { pro.active = 1 - pro.active ; drawpro(pro,segments,pro.sel) ; return ; } 
  else if(pro.active==0) 
  { if(mouseopt) 
    { e.latLng = point2LatLng(e.clientX,e.clientY,pro.map) ; selpoint(e) ; }
    return ; 
  }

  flag = infowindow.close() ;
  if(flag=='highlight') flag = 3 ; 
  else if(flag=='seginfo') flag = 2 ; 
  else if(flag=='wpinfo') flag = 1 ; 
  else flag = 0 ; 
  pos = Math.floor(0.5+(610-pos)*pro.ratio) ;
  if(pos>=pro.m.pro2wp.length) pos = pro.m.pro2wp.length-1 ; 
  else if(pos<0) pos = 0 ; 
  sel = pro.m.pro2wp[pos] ;
  walkto(sel[0],sel[1],flag) ;
} 
function point2LatLng(x,y,map) // by Egil (stackoverflow)
{ var topRight = map.getProjection().
                         fromLatLngToPoint(map.getBounds().getNorthEast());
  var bottomLeft = map.getProjection().
                         fromLatLngToPoint(map.getBounds().getSouthWest());
  var scale = Math.pow(2, map.getZoom());
  var pt = new google.maps.Point(x/scale + bottomLeft.x, y/scale + topRight.y);
  return map.getProjection().fromPointToLatLng(pt);
}
/* -------------------------------------------------------------------------- */

• xmlfloat     • isvaliddate     • isvalidnum     • datatype     • function     • addlabel     • propstype     • getdatum     • getprops     • readtcx     • readgpx     • readfit     • readfitvalue     • readfitangle     • dist     • angle     • optimise     • gettags     • writegps     • gentag     • addpos     • gpxpos     • getstats     • writeindex     • gencolours     • ascify     • flatten     • getalts     • profilemaptype

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

   Copyright (c) 2016/2017, 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) 
{ 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:null,icon:this.geticon(),title:c,zIndex:1 }) ;
  else { this.marker.setIcon(this.geticon()) ; this.marker.setTitle(c) ; }
} ;
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) 
{ var i ; 
  for(i=0;i<p.length;i++) this.photo.push(p[i]) ; 
  if(this.photomarker==null) this.photomarker = new google.maps.Marker
      ({ position:this.pos,map:null,icon:icons.camera,title:p[0],zIndex:1 }) ;
  if(this.photo.length==1) this.photomarker.setTitle(this.photo[0]) ; 
  else this.photomarker.setTitle(this.photo[0]+'+'+(this.photo.length-1)) ;
} ;
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) 
{ var i,datum,mindist ; 
  for(datum=null,i=0;i<data.length;i++) 
    if(datum==null||dist(pos,data[i].pos)<mindist) 
  { mindist = dist(pos,data[i].pos) ; datum = data[i] ; } 
  if(datum!=null) datum.setlabel(type,caption) ;
}
function propstype()
{ this.desc = this.title = this.list = this.inputlen = this.filename = null ;
  this.stats = this.tlink = this.index = this.stars = this.srcid = null ; 
  this.origin = this.date = null ; 
  this.photo = [] ;
  this.optim = { already: 0, ndel: 0, origlen: 0, parms: null }
}
/* -------------------------------------------------------------------------- */

function getdatum(node,tags,m,photosel)
{ var lat=null,lon=null,alt=null,time=null,j,nodeno,nodes,photo=[],tag ;
  var pos,ind,validalt,valid,title=null,type=null ; 

  if(tags.mode=='gpx')
  { lat = parseFloat(node.getAttribute('lat')) ; 
    lon = parseFloat(node.getAttribute('lon')) ; 
  }
  nodes = node.childNodes ;
  
  for(validalt=valid=1,nodeno=0;nodeno<nodes.length;nodeno++)
  { node = nodes[nodeno] ;

    if(node.nodeName==tags.el) alt = xmlfloat(node) ; 
    else if(node.nodeName==tags.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==tags.extns) for(j=0;j<node.childNodes.length;j++)
    { tag = node.childNodes[j].nodeName ;
      if(tag==tags.photo) photo = node.childNodes[j].textContent.split(' ') ;
      else if(tag==tags.vtime) valid = 0 ;
      else if(tag==tags.vel) validalt = 0 ;
    }
    else if(node.nodeName==tags.type) type = node.textContent ;
    else if(node.nodeName==tags.title) title = node.textContent ;
  }
  if(lat==null||lon==null) return null ; 
  if(!isvalidnum(alt)) validalt = 0 ; 
  pos = new google.maps.LatLng(lat,lon) ;
  datum = new datatype(pos,validalt?alt:null,valid?time:null) ; 
  return { datum:datum , photo:photo , title:title , type:type } ;
}
/* -------------------------------------------------------------------------- */

function getprops(nodes,tags,xmlfile)
{ var props = new propstype() , node,i,id,field,txt ;

  for(i=0;i<nodes.length;i++)
  { node = nodes[i] ; 
    id = node.nodeName ;
    if(id=='LongTitle') id = 'Description' ;
    else if(id=='Overview') id = 'Index' ; 
    for(field in tags) if(tags[field]==id)
    { if(field=='opt')
      { props.optim.already = 1 ; 
        props.optim.origlen = parseInt(node.getAttribute('from')) ; 
        props.optim.ndel = 
          props.optim.origlen - parseInt(node.getAttribute('to')) ; 
        props.optim.parms = 
          { tol: parseFloat(node.getAttribute('tol')) ,
            maxsep: parseFloat(node.getAttribute('maxsep')) ,
            wppenalty: parseFloat(node.getAttribute('wppenalty')) ,
            vweight: parseFloat(node.getAttribute('vweight')) 
          } ; 
        continue ; 
      }
      if(field=="list") txt = node.getAttribute('src') ;
      else if(field=="index"||field=="tlink") 
        txt = node.getAttribute('href') ;
      else txt = node.childNodes[0].textContent ;
      if(field=="list"||field=="index"||field=="tlink")
        txt = reluri(xmlfile,txt) ;
      if(field=='photo') props.photo = txt.match(/\S+/g) ;
      else props[field] = txt ;
    }
  }
  return props ; 
}
/* -------------------------------------------------------------------------- */

function readtcx(xmldoc,xmlfile)
{ var nodeno,i,j,k,node,segment,props,title,nsegment,course,r,nodes ;
  var tags = new gettags('tcx') ; 

  // get global title
  course = xmldoc.getElementsByTagName('TrainingCenterDatabase')[0].childNodes ;
  for(title=null,i=0;title==null&&i<course.length;i++) 
    if(course[i].nodeName=='Courses') 
      for(nodes=course[i].childNodes,j=0;title==null&&j<nodes.length;j++) 
        if(nodes[j].nodeName=='Name') title = nodes[j].textContent ;

  // maybe the tracks hang from courses, maybe from laps
  course = xmldoc.getElementsByTagName('Track') ;
  if(course.len==0) { alert('No tracks') ; throw '' ; }

  // loop over courses 
  course = xmldoc.getElementsByTagName(course[0].parentNode.nodeName) ;
  nsegment = course.length ;
  segment = new Array(nsegment) ; 
  props = new Array(nsegment) ; 
  for(i=0;i<nsegment;i++) 
  { segment[i] = new Array() ; props[i] = new propstype() ; }

  for(i=0;i<nsegment;i++) 
  { // get properties
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName==tags.extns)
        props[i] = getprops(course[i].childNodes[j].childNodes,tags,xmlfile) ; 

    // get title and trackpoints
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='Name')
    { props[i].title = course[i].childNodes[j].textContent ; 
      if(title==null) title = props[i].title ;
    }
    else if(course[i].childNodes[j].nodeName=='Track')
    { for(k=0;k<course[i].childNodes[j].childNodes.length;k++)
      { node = course[i].childNodes[j].childNodes[k] ;
        if(node.nodeName=='Trackpoint') // beware: NOT 'TrackPoint'
          if((r=getdatum(node,tags))!=null)  
        { segment[i].push(r.datum) ; 
          segment[i][segment[i].length-1].addphoto(r.photo) ;
        }
      }
      props[i].inputlen = segment[i].length ; 
      if(props[i].optim.origlen==0) props[i].optim.origlen = segment[i].length ;
    }

    // get coursepoints
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='CoursePoint')
        if((r=getdatum(course[i].childNodes[j],tags))!=null)
          addlabel(segment[i],r.datum.pos,r.type,r.title) ; 
  }

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

function readgpx(xmldoc,xmlfile)
{ var xmlcoords,i,data,r, props = new propstype() , tags = new gettags('gpx') ;

  // 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++)
    if((r=getdatum(xmlcoords[i],tags))!=null)
  { data.push(r.datum) ; data[data.length-1].addphoto(r.photo) ; }

  // loop over the waypoints to get the labels
  xmlcoords = xmldoc.getElementsByTagName('wpt') ;
  for(i=0;i<xmlcoords.length;i++)
    if((r=getdatum(xmlcoords[i],tags))!=null)
      addlabel(data,r.datum.pos,r.type,r.title) ; 

  // get the routemaster properties
  xmlcoords = xmldoc.getElementsByTagName('gpx')[0].childNodes ;
  for(i=0;i<xmlcoords.length;i++) if(xmlcoords[i].nodeName==tags.extns)
    props = getprops(xmlcoords[i].childNodes,tags,xmlfile) ; 

  props.inputlen = data.length ; 

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

  // get the route description
  if(props.desc==null)
  { xmlcoords = xmldoc.getElementsByTagName('desc') ;
    if(xmlcoords.length>0&&xmlcoords[0].childNodes.length>0) 
      props.desc = xmlcoords[0].childNodes[0].textContent ;
  }
  return { props:[props] , segments:[data] } ;
}
/* -------------------------------------------------------------------------- */

function readfit(rawdata)
{ var input = new Uint8Array(rawdata) , defns=[] , props = new propstype ; 
  var ind,val,flag,tag,i,defn,lat,lon,pos,alt,time,item,nitem ;
  if(input.length<12)
  { alert("supposed FIT file has length<12 ("+input.length+")") ; throw '' ; }
  tag = String.fromCharCode(input[8]) + String.fromCharCode(input[9]) + 
        String.fromCharCode(input[10]) + String.fromCharCode(input[11]) ;
  if(tag!=".FIT") 
  { alert("supposed FIT file has format designator '"+tag+"'") ; throw '' ; }

  for(data=[],ind=input[0];ind<input.length-2;) 
  { flag = input[ind++] ; 
    if(128&flag) { alert("FIT file is compressed") ; throw '' ; }
    else if(64&flag)     // definition message
    { val = input[ind+2] + (input[ind+3]<<8) ; 
      defn = { num:val , type:15&flag , fields:[] , size:0 } ; 
      nitem = input[ind+4] ;
      for(ind+=5,i=0;i<nitem;i++,ind+=3)
      { item = { num:input[ind] , size:input[ind+1] , type:input[ind+2] } ;
        defn.fields.push(item) ; 
        defn.size += item.size ; 
      }
      defns.push(defn) ; 
    }
    else
    { for(defn=null,i=0;defn==null&&i<defns.length;i++) 
        if(defns[i].type==(15&flag)) defn = defns[i] ; 
      if(defn==null) { alert("missing definition in FIT file") ; throw '' ; }
      if(defn.num==20) 
      { lat = lon = alt = time = null ; 
        for(i=0;i<defn.fields.length;i++,ind+=item.size)
        { item = defn.fields[i] ; 
          if(item.num==0) lat = readfitangle(input,ind,item.type) ;
          else if(item.num==1) lon = readfitangle(input,ind,item.type) ;
          else if(item.num==2) 
            alt = readfitvalue(input,ind,item.type)/5 - 500  ;
          else if(item.num==253) 
            time = 1000 * ( readfitvalue(input,ind,item.type) + 631065600 ) ;
        }
        if(lat!=null&&lon!=null)
        { pos = new google.maps.LatLng(lat,lon) ;
          data.push(new datatype(pos,alt,new Date(time))) ; 
        }
      }
      else ind += defn.size ; 
    }
  }
  props.inputlen = props.origlen = data.length ; 
  return { props: [props] , segments: [data] } ;
}
/* -------------------------------------------------------------------------- */

function readfitvalue(input,ind,type)
{ var r=null ;
  if(type==1||type==2) r = input[ind] ; 
  else if(type==131||type==132) r = input[ind] + (input[ind+1]<<8) ; 
  else if(type==133||type==134) r = input[ind] + (input[ind+1]<<8) + 
                            (input[ind+2]<<16) + (input[ind+3]<<24) ; 
  if(r!=null&&(type&1)==0) r = r >>> 0 ; // type-convert to unsigned
  return r ; 
}
function readfitangle(input,ind,type)
{ return readfitvalue(input,ind,type)*180 / ((1 << 31) >>> 0) ; }

/* -------------------------------------------------------------------------- */
/*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*/
/* -------------------------------- gettags  -------------------------------- */

function gettags(mode)
{ var field ; 
  this.time =  'Time' ;
  this.extns = 'Extensions' ; 
  this.title = 'Name' ; 
  this.opt =   'Optimised' ; 
  this.stars = 'Stars' ; 
  this.stats = 'Stats' ; 
  this.date =  'Date' ; 
  this.desc =  'Description' ; 
  this.list =  'PhotoList' ; 
  this.tlink = 'TrackLink' ; 
  this.photo = 'Photo' ; 
  this.index = 'Index' ; 
  this.srcid = 'SourceId' ; 
  this.vtime = 'ValidTime' ; 
  this.vel =   'ValidAlt' ; 
  if(mode=='tcx') { this.el = 'AltitudeMeters' ; this.type = 'PointType' ; }
  else 
  { for(field in this) this[field] = this[field].toLowerCase() ;
    this.el = 'ele' ; 
    this.type = 'type' ; 
  }
  this.mode = mode ; 
}
/* -------------------------------------------------------------------------- */

function writegps(props,idata,mode) 
{ var i,j,k,h,xmldoc,course,lap,filename,track,time,routelen,nnull,sum,title ; 
  var trackpoint,coursepoint,str,exstr,flag,clen=idata.length,tlast,di,dk ;
  var origlen,ndel,ano,photo,thisuri,sep,tdist,ttime,time,otime,x,y,pointdist ; 
  var distance = new Array(clen) , msecs = new Array(clen) , date ;
  var valid = new Array(clen) , alt = new Array(clen) ;
  var tags = new gettags(mode) , title = props.title ;
  if(mode=='tcx') title = title.substring(0,15) ; 

  for(date=tlast=null,nnull=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) 
    { if(date==null) date = time.toDateString() ; 
      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(time!=null&&otime!=null) { tdist += sep ; ttime += time - otime ; }
    }
    else distance[i] = 0 ; 
  }
  routelen = distance[clen-1] ;

  // patch up missing altitudes
  if(nnull==clen) for(i=0;i<clen;i++) alt[i] = 0 ; 
  else 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(date=null,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) ;
  }

  // header
  if(mode=='js') str = '// javascript gps track for web display\n' ;
  else str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' ;
  if(mode=='tcx') 
    str += '<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' ;
  else if(mode=='gpx') 
    str += '<gpx xmlns="http://www.topografix.com/GPX/1/1" version="1.1"\n' +
           'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n' +
           'xsi:schemaLocation="http://www.topografix.com/GPX/1/1 ' + 
           'http://www.topografix.com/GPX/1/1/gpx.xsd">\n' ;
  if(mode=='js') 
    str += '// http://www.masterlyinactivity.com/software/routemaster.html\n' ;
  else str += 
     '<!-- http://www.masterlyinactivity.com/software/routemaster.html -->\n' ;

  // metadata
  time = (msecs[clen-1]-msecs[0]) / 1000 ;
  if(mode=='tcx') str += '  <Folders><Courses><CourseFolder Name="Courses">\n' +
           '        <CourseNameRef><Id>'+title+'</Id></CourseNameRef>\n' +
           '  </CourseFolder></Courses></Folders>\n<Courses><Course>\n' +
           '  ' + gentag(tags.title,title) + '\n  <Lap>\n' + 
           '    ' + gentag('DistanceMeters',routelen.toFixed(0)) + '\n' +
           '    ' + gentag('TotalTimeSeconds',time.toFixed(0)) + '\n' +
           '    ' + addpos('Begin',idata[0].pos) + 
           '    ' + addpos('End',idata[clen-1].pos) + 
           '    ' + gentag('Intensity','Active') + '\n  </Lap>\n  <Track>\n' ; 
  else if(mode=='gpx') str += '<metadata>' + gentag(tags.title,title) + 
       '</metadata>\n<trk>' + gentag(tags.title,title) + '\n<trkseg>\n' ;

  // loop over trackpoints
  for(i=0;i<idata.length;i++) 
  { if(mode=='tcx') str += '  <Trackpoint>\n    ' + addpos('',idata[i].pos) + 
     '    ' + gentag('DistanceMeters',distance[i].toFixed(0)) + '\n' ; 
    else str += '  <trkpt ' + gpxpos(idata[i].pos) + '>\n' ; 
    if(idata[i].h==null) h = alt[i] ; else h = idata[i].h ; 
    str += '    ' + gentag(tags.el,h.toFixed(0)) + '\n' +
           '    ' + gentag(tags.time,new Date(msecs[i]).toISOString()) + '\n' ; 
    if(idata[i].photo.length>0||valid[i]==0||idata[i].h==null)
    { str += '    <' + tags.extns + '>\n' ; 
      if(idata[i].photo.length>0)
      { str += '      <' + tags.photo + '>'
        for(k=0;k<idata[i].photo.length;k++)
        { if(k) str += ' ' ; str += idata[i].photo[k] ; }
        str += '</' + tags.photo + '>\n' ; 
      }
      if(valid[i]==0) str += '      ' + gentag(tags.vtime,'False') + '>\n' ;
      if(idata[i].h==null) str += '      ' + gentag(tags.vel,'False') + '\n' ;
      str += '    </' + tags.extns +'>\n' ; 
    }
    if(mode=='gpx') str += '  </trkpt>\n' ;
    else str += '    <SensorState>Absent</SensorState>\n  </Trackpoint>\n' ; 
  }

  if(mode=='tcx') str += '  </Track>\n\n' ;
  else str += '</trkseg>\n</trk>\n\n' ;

  // add routemaster extensions
  exstr = '' ; 
  if(props.optim.ndel) 
  { exstr += '    <' + tags.opt + ' from="' + props.optim.origlen +
                          '" to="' + (props.optim.origlen-props.optim.ndel) ;
    if(props.optim.parms!=null) 
      exstr += '" 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) ;
    exstr += '"/>\n'
  }
  if(props.list!=null) 
    exstr += '    <' + tags.list + ' src="' + props.list + '"/>\n' ; 
  if(props.stars!=null) 
    exstr += '    ' + gentag(tags.stars,props.stars) + '\n' ;
  if(props.desc!=null) exstr += '    ' + gentag(tags.desc,props.desc) + '\n' ; 
  if(props.date!=null) exstr += '    ' + gentag(tags.date,props.date) + '\n' ; 
  if(props.index!=null) 
    exstr += '    <' + tags.index + ' href="' + props.index + '"/>\n' ; 
  if(props.srcid!=null&&props.srcid!=props.title) 
    exstr += '    ' + gentag(tags.srcid,props.srcid) + '\n' ; 
  if(exstr.length>0) 
    str += '  <' + tags.extns +'>\n' + exstr + '  </' + tags.extns + '>\n\n' ;

  // finally loop over coursepoints
  if(mode=='tcx'||mode=='gpx') for(i=0;i<idata.length;i++) 
    if(idata[i].type!=null)
  { if(mode=='tcx') str += '  <CoursePoint>\n    ' + addpos('',idata[i].pos) ;
    else str += '  <wpt ' + gpxpos(idata[i].pos) + '>\n' ; 
    str += '    ' + gentag(tags.title,idata[i].marker.title) + '\n' +
           '    ' + gentag(tags.type,idata[i].type) + '\n' +
           '    ' + gentag(tags.el,idata[i].h.toFixed(0)) + '\n' +
           '    ' + gentag(tags.time,new Date(msecs[i]).toISOString()) + '\n' ; 
    if(mode=='tcx') str += '  </CoursePoint>\n' ;
    else str += '  </wpt>\n' ;
  }

  if(mode=='tcx') 
    return str + '</Course></Courses></TrainingCenterDatabase>\n' ; 
  else if(mode=='gpx') return str + '</gpx>\n' ;
}
/* -------------------------------------------------------------------------- */

function gentag(tag,val) { return '<' + tag + '>' + val + '</' + tag + '>' ; }
function addpos(pfx,pos)
{ return '<' + pfx + 'Position>\n      ' +
           gentag('LatitudeDegrees', pos.lat().toFixed(5)) + '\n      ' +
           gentag('LongitudeDegrees',pos.lng().toFixed(5)) + '\n    </' + 
           pfx + 'Position>\n' ;
}
function gpxpos(pos) 
{ return 'lat="' + pos.lat().toFixed(5) + 
       '" lon="' + pos.lng().toFixed(5) + '"' ; 
}
/* -------------------------------- getstats  ------------------------------- */

function getstats(idata,props,filename,origin)
{ var oh=null,i,h,routelen,maxalt=null,minalt=null,up,down ;
  for(props.photo=[],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 ; } 
    if(props.date==null&&idata[i].t!=null)
      props.date = idata[i].t.toDateString() ; 
    props.photo = props.photo.concat(idata[i].photo) ;
  }
  props.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' ;

  if(origin=='uri') props.tlink = reluri(document.URL,'?track='+filename) ; 
  else props.tlink = reluri(document.URL,'?track=$FILE$/'+filename) ; 
}
/* ------------------------------- writeindex  ------------------------------ */

function writeindex(segments,title,list,parms) 
{ var i,j,ndata,photo,segno,idata,props ;
  var uri,tags=new gettags('tcx') ; 
  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 += gentag('Name',title) ; 
  str += '\n' ; 

  for(segno=0;segno<segments.length;segno++)
  { props = segments[segno].props ;
    idata = segments[segno].data ;
    if(props.stats==null)  
    { getstats(idata,props,props.origin[0],props.origin[1]) ;
      if(parms==null||parms==undefined) idata = ndata; 
      else 
      { for(ndata=new Array(idata.length),i=0;i<idata.length;i++)
          ndata[i] = new datatype(idata[i].pos,0) ;
        idata = optimise(ndata,parms) ;
      }
    }

    str += '<Course>\n' ;
    if(props.title!=null) str += '  ' + gentag(tags.title,props.title) + '\n' ;
    str += '  <Track>\n' ; 

    // loop over trackpoints
    for(i=0;i<idata.length;i++) str += '    <Trackpoint>\n    ' + 
                              addpos('',idata[i].pos) + '    </Trackpoint>\n' ; 
    str += '  </Track>\n\n  <Extensions>\n' ;
    if(props.stars) str += '    ' + gentag(tags.stars,props.stars) + '\n' ; 
    if(props.desc)  str += '    ' + gentag(tags.desc,props.desc) + '\n' ; 
                    str += '    ' + gentag(tags.stats,props.stats) + '\n' ;
    if(props.date)  str += '    ' + gentag(tags.date,props.date) + '\n' ; 
    if(props.tlink) str += '    <TrackLink href="' + props.tlink + '"/>\n' ; 
    if(list)        str += '    <PhotoList src="' + list + '"/>\n' ; 

    if(props.photo.length)
    { str += '    <Photo>' ;
      for(i=0;i<props.photo.length;i++) 
      { if(i>0&&i%6==0) str += '\n           ' ; else if(i) str += ' ' ; 
        str += props.photo[i] ; 
      }
      if(props.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 = 'śš' , esss = 'ß' , u = 'ûüùúū' ;
  var ae = 'æ' , oe = 'œ'
  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(ae.indexOf(c)>=0) newc = 'ae' ; 
    else if(oe.indexOf(c)>=0) newc = 'oe' ; 
    else if(esss.indexOf(c)>=0) newc = 'ss' ; 
    else if(u.indexOf(c)>=0) newc = 'u' ; 
    else newc = '*' ;
    if(c==C) sdash += newc ; else sdash += newc.toUpperCase() ; 
  }
  return sdash ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* --------------------------------- flatten -------------------------------- */

function flatten(segments)
{ var point,npoint,d,s0,s1,len,pos,opos ;

  for(npoint=s0=0;s0<segments.length;npoint+=segments[s0].data.length,s0++) ;
  point = new Array(npoint) ;
  for(d=npoint=s0=0;s0<segments.length;s0++)
    for(len=segments[s0].data.length,s1=0;s1<len;s1++,opos=pos) 
  { pos = segments[s0].data[s1].pos ;
    if(npoint) d += dist(opos,pos) ;
    point[npoint++] = 
      { pos:pos , h:segments[s0].data[s1].h , sel:[s0,s1] , d:d } ;
  }
  return point ; 
}
/* --------------------------------- getalts -------------------------------- */

var elevator=null,reqlist=[],altthresh=null,altcallback=null ; 

function getalts(segments,thresh,action,doneaction)
{ var s0,s1,start,end,n,npts,flag,lox,loxpos,loxind,point,i,j,k,l,len ; 
  var reqno,ind ; 

  if(reqlist.length>0) 
  { if(altthresh==null||thresh<altthresh) altthresh = thresh ; 
    if(doneaction!=undefined) altcallback = doneaction ;
    return ;
  }
  else if(thresh==null) return ; 

  if(elevator==null) elevator = new google.maps.ElevationService ;
  if(altthresh!=null&&altthresh<thresh) thresh = altthresh ;
  if(altcallback!=null&&doneaction==undefined) doneaction = altcallback ; 

  point = flatten(segments) ; 

  for(flag=npts=i=0;i<point.length;i=j)
  { if(point[i].h!=null) { j = i+1 ; continue ; }
    for(j=i+1;j<point.length&&point[j].h==null;j++) ;
    // so now i is the first of a sequence of null altitudes and j is the 
    // non-null altitude terminating it
    if(i>0) i -= 1 ; 
    if(j<point.length) j += 1 ; 
    n = j - i ; 
    if(npts+n<=500) { npts += n ; reqlist.push([i,j]) ; }
    else if(reqlist.length>0) // unable to process correctly in this request
    { flag = 1 ; break ; }
    else { npts = n ; reqlist.push([i,j]) ; flag = 2 ; break ; }
  }
  if(flag==0&&npts<thresh) 
  { reqlist = [] ; 
    altthresh = altcallback = null ; 
    if(doneaction!=undefined&&doneaction!=null) doneaction() ;
    return ; 
  }

  for(lox=new Array(npts),ind=reqno=0;reqno<reqlist.length;reqno++) 
    for(end=reqlist[reqno][1],k=reqlist[reqno][0];k<end;k++) 
      lox[ind++] = segments[point[k].sel[0]].data[point[k].sel[1]] ; 
  point = null ; // we've finished with it

  if(flag==2) for(loxind=new Array(501),loxpos=new Array(501),k=0;k<=500;k++)
  { loxind[k] = Math.floor(0.5+(k*(n-1))/500) ; 
    loxpos[k] = lox[loxind[k]].pos ; 
  }
  else for(loxind=new Array(npts),loxpos=new Array(npts),k=0;k<npts;k++) 
  { loxind[k] = k ;  loxpos[k] = lox[k].pos ; }

  // at this point lox is an array of points whose altitudes need adjusting, 
  // loxpos is an array of at most 500 positions and loxind holds the indexes
  // in lox of the entries in loxpos

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

  elevator.getElevationForLocations( {locations:loxpos},function(results,status)
  { // assume that the results come in sequence, ie. correspond to xpending[0]
    var d0,dn,reqno,i,j,k,l,n,err,d,D ;
    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!=loxpos.length) err = 1 ; 
    else for(err=i=0;i<loxpos.length&&err==0;i++) 
      if(dist(loxpos[i],results[i].location)>5) err= 1 ; 
    if(err) 
    { alert("elevation response does not correspond to request") ; throw '' ; }

    for(k=reqno=0;reqno<reqlist.length;reqno++,k+=n) 
    { if(flag==2) n = 501 ; else n = reqlist[reqno][1] - reqlist[reqno][0] ; 
      if(lox[loxind[k]].h!=null&&lox[loxind[k+n-1]].h!=null)
      { d0 = lox[loxind[k]].h - results[k].elevation ;
        dn = lox[loxind[k+n-1]].h - results[k+n-1].elevation ;
      }
      else
      { if(lox[loxind[k]].h!=null) 
         dn = d0 = lox[loxind[k]].h - results[k].elevation ; 
        else if(lox[loxind[k+n-1]].h!=null) 
          dn = d0 = lox[loxind[k+n-1]].h - results[k+n-1].elevation ; 
        else dn = d0 = 0 ; 
      }
      for(i=0;i<n;i++) lox[loxind[k+i]].h = 
                         results[k+i].elevation + (i*dn+((n-1)-i)*d0)/(n-1) ;
      // fill in missing altitudes by interpolation
      if(flag==2) for(i=0;i<n-1;i++) if(loxind[i+1]>loxind[i]+1)
      { for(D=0,k=loxind[i];k<loxind[i+1];k++) 
          D += dist(lox[k].pos,lox[k+1].pos) ; 
        dn = lox[loxind[i+1]].h ; 
        d0 = lox[loxind[i]].h ; 
        for(d=0,k=loxind[i];k<loxind[i+1]-1;k++) 
        { d += dist(lox[k].pos,lox[k+1].pos) ; 
          lox[k+1].h = ( d*dn+(D-d)*d0 ) / D ; 
        }
      }
    }
    reqlist = [] ; 
    if(action!=null&&action!=undefined) action(npts) ; 
    getalts(segments,thresh,action,doneaction) ; 
  } ) ;
}
/* -------------------------------------------------------------------------- */
/*page*/
/* -------------------------------------------------------------------------- */
/*          FUNCTIONS FOR COMPUTING & DISPLAYING THE ALTITUDE PROFILE         */
/* -------------------------------------------------------------------------- */

function profilemaptype(segments,nx)
{ var i,d0,d1,D,pos,oldpos,hind,htarg,oldi,lam,n,s0,s1 ; 

  var point = flatten(segments) ;
  n = point.length ; 
  for(j=0;j<n&&point[j].h==null;j++) ; 
  if(j==n) return null ;
  for(i=0;i<j;i++) point[i].h = point[j].h 
  for(i=n;i>0&&point[i-1].h==null;i--) ; 
  for(j=i;j<n;j++) point[j].h = point[i-1].h ;
  D = point[point.length-1].d ; 

  // now loop through points extracting regularly spaced altitudes
  this.h = new Array(nx) ; 
  for(oldi=hind=i=0;hind<nx;hind++)
  { htarg = (hind+0.5) * D / nx ;
    for(;i<point.length-1&&(point[i].d<htarg||point[i].h==null);i++) 
      if(point[i].h!=null) oldi = i ; 
    d1 = point[i].d ;
    d0 = point[oldi].d ;
    if(d1>d0) lam = (htarg-d0) / (d1-d0) ; else lam = 1 ; 
    if(lam>0&&lam<1&&point[i].sel[1]==0) this.h[hind] = null ; 
    else this.h[hind] = point[oldi].h + lam*(point[i].h-point[oldi].h) ; 
  }

  // a simple loop to find hmin, hmax
  for(i=0;i<nx;i++) if(this.h[i]!=null)
  { if(i==0||this.h[i]>this.hmax) this.hmax = this.h[i] ;
    if(i==0||this.h[i]<this.hmin) this.hmin = this.h[i] ;
  }
  if(this.hmin>0) 
  { if(this.hmax>3*this.hmin) this.hmin = 0 ; 
    else this.hmin *= 1 - (this.hmax/this.hmin-1)/2 ; 
  }
  this.hspan = Math.max(1,this.hmax-this.hmin) ; 

  // set up pro2wp
  this.pro2wp = new Array(nx) ; 
  for(hind=i=0;hind<nx;hind++)
  { htarg = (hind+0.5) * D / nx ;
    for(;i<point.length-1&&point[i].d<htarg;i++) ;
    this.pro2wp[hind] = point[i].sel ;
    if(i==0) continue ;
    d1 = point[i].d ;
    d0 = point[i-1].d ;
    if(d1>d0) lam = (htarg-d0) / (d1-d0) ; else lam = 1 ; 
    if(lam<0.5) this.pro2wp[hind] = point[i-1].sel ;
  }

  // set up wp2pro
  this.wp2pro = new Array(segments.length) ; 
  for(i=s0=0;s0<segments.length;s0++)
  { n = segments[s0].data.length ;
    this.wp2pro[s0] = new Array(n) ;
    for(s1=0;s1<n;s1++,i++)
      this.wp2pro[s0][s1] = Math.floor(0.5+point[i].d*nx/D) ;
  }
}
/* -------------------------------------------------------------------------- */

routemaster.js routemasteredit.js routemasterui.js routemasterlib.js
pix.html: pixlib.js