simple tasks : advanced hints : photos and indexes : technical doc : about the software : routemasterlib doc : Garmin 500/130+ review : source

Routemaster is an online tool to display and edit GPS tracks and to display multi-track indexes. Photographs may be included. The GPS tracks may be in TCX, GPX, KML or FIT format, or may be Google Maps direction pages. You can either upload a track from your own computer or view a track stored anywhere on the web.

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 on the welcome page. The following hints cover the simplest tasks.

New! routemaster now smoothes altitudes during optimisation, allowing an increase in their accuracy.

New! routemaster now has full capability of processing .fit files, for either input or output. This is recommended for output to Garmin devices. It also has a limited capability for input of csv files.

• how to edit     • selecting a point     • modifying a point’s properties     • moving a point     • deleting points     • adding points     • interpolating points     • adding turn instructions     • adding a track description     • splitting a track into segments     • reversing a track     • the distance between two points     • adjusting altitudes     • changing the start point     • combining two tracks     • saving a track     • experimenting with routemaster     • loading from the internet     • importing Google Maps directions     • creating a routemaster account     • reporting bugs

Bring up routemaster, click on the Browse button, and choose a GPX, TCX, FIT or KML file from your file system. It will be displayed against a Google map. Various editing operations are available from the iconised buttons at the bottom of the screen and from keyboard shortcuts. Clicking on the cogwheel button and then on ‘Help’ gives you a succinct reminder of routemaster’s functions and a link to full documentation.

When the file is loaded it will be optimised (i.e. the number of points reduced to the minimum needed for accuracy). You can navigate between track points either by clicking on the route, in which case the nearest point will be selected, or by using the left and right arrow buttons to move between points.

If you don’t want the optimisation, you can click on the ‘Undo’ button (backwards arrow) immediately after loading.

If you click on the ‘Waypoint properties’ button (third from the left) you will be able to change some of the point’s properties.

If you click on the fountain pen icon you will be able to add turn information or a label saying (for instance) that the point is a peak or valley.

If you view a track against the satellite image you may decide you want to adjust the position of a point. Select it and hit the space bar, making it draggable. Drag it to the position you want with the mouse, then hit space again.

To delete a single point, select it and hit the delete or backspace button. You can’t do any harm because the action can be undone.

To delete a set of points you can delete the points one by one. Alternatively you can split the track into segments (see below) so that one segment contains the points you want to delete. You can then delete the entire segment by hitting shifted delete or backspace. Then combine the remaining segments by clicking the broken cogwheel button and choosing ‘Combine with previous/next segment’.

Suppose you have a GPS track for a forest descent, and want to extend it until it reaches the nearest road.

Load the track and select satellite imagery. Point at where you want the first point of the extension, and shift-click with the mouse. Do this repeatedly until you have all the points you want.

Suppose you want to extend the track backwards, to show how you reach it from the road. Then reverse the track, extend it as before, and reverse it back.

If you want to add points by interpolation, see the next section.

If you want to insert a point between two successive track points, select the earlier of them and hit the tab key. This inserts a draggable point midway. Move it with the mouse until you’re happy with its position, then hit the space bar.

You can interpolate backwards by hitting shift-tab, but this doesn’t serve much purpose.

To attach a label to a point along a track, select it and hit the fountain pen button. (Note that you cannot label anything other than a point along the track. If you want to put a label between two trackpoints, interpolate first, then add a label.)

A labelled point is called a coursepoint. It has both a caption (known as its ‘name’) and a type. The type is drawn from a limited set (‘Turn left’, ‘Danger’ etc.) and an icon is associated with each type. The caption is an arbitrary text string adding extra information (eg. ‘trattoria’) which may be empty. The sets of permitted labels differ between TCX and FIT; GPX does not adequately support coursepoints.

routemaster offers a subset of the FIT coursepoint types, most of which belong to the TCX set. The types it offers are:
Type    TCX?    Meaning/example
GenericAnything not covered by other available types. Almost always needs a caption.
Sharp leftEg. 135° turn.
Left
Slight leftEg. 45° turn.
StraightI.e. straight on
Slight rightEg. 45° turn.
Right
Sharp rightEg. 135° turn.
 
DangerEg. exposed path. Normally needs a caption.
FoodEg. a mountain refuge.
WaterEg. a water fountain.
SummitA high point on the route (not necessarily a true summit).
ValleyA low point on the route (not necessarily a true valley).
First Aid
InfoEg. a sign board.
ObstacleEg. a locked gate. Normally needs a caption.

If you use a coursepoint absent from the TCX set and then export the course as TCX, then the type will be converted to one supported by TCX: either Left, Right, or Generic as appropriate. Make sure you provide a caption so that the meaning isn’t lost.

I formerly limited the name to 10 characters, but I’ve handled tracks from other sources with longer names and even my Garmin device didn’t throw any problems. So I now allow arbitrarily long names. On the other hand, I have recently relaxed the requirement that you have to provide at least one character since names are optional for FIT coursepoints.

Both TCX and GPX associate labels with arbitrary geographic locations (not necessarily lying on a track). This is somewhat unnatural for their normal use (eg. indicating turning directions). Worse, it can lead to confusion because the same location may occur more than once on a track, eg. on the outward leg and the return. A right turn in one direction is a left turn in the other.

My practice in routemaster is to associate labels internally with points along a route, but to print them out identified by their geographic locations. And in order to avoid confusion, I make sure that if another point coincides with a labelled point (to the floating-point precision I adopt) I nudge one of the points by a few metres to keep them apart.

There is an ‘add description’ option under the cogwheel menu. It allows some rudimentary HTML formatting. See more details below.

Splitting a track into segments is useful because it allows you to adjust part of a track as if it was an entire route (e.g. add 10m to its altitude).

Select the first point of the segment after the desired split point and hit the scissors button.

Click on the segment properties button (broken cogwheel) and click on ‘Reverse segment’.

If your route comprises a single segment, this reverses the route. If it comprises more than one segment, you can reverse them separately.

Split the route into segments, one of which spans the distance you want to measure. Select a point on the segment in question and click the ‘Segment properties’ button (broken cogwheel). The length of the segment (and some other properties) will be displayed. Then recombine the segments, either from the cogwheel button, the broken cogwheel, or the ‘Undo’ button.

There are many options for supplying and correcting altitudes.

Firstly, if any points have no altitudes, their number will be shown in the route props box (cogwheel menu). You have the option to ‘find altitudes’. This uses the Google Elevations Service to estimate the difference in altitude compared with nearby points whose altitudes are known; hence the results will be consistent with those already present, even if these are miscalibrated.

There’s no need to invoke this option manually: when you attempt to save a route, it will be done automatically for you.

Secondly, if you want to change or delete an altitude, you can do so from the waypoint props box.

Thirdly, if you want to delete all altitudes in a segment, you get this option from ‘Adjust altitudes...’ in the segment props box (broken cogwheel menu).

Fourthly, if you happen to know a calibration offset for your points, you can add it by invoking an option available from ‘Adjust altitudes...’. However linear regression is likely to be better.

Fifthly, if your altitudes are not reliable (or you don’t have any), you can get values from the Google Elevations Service.

Finally, if your altitudes have been measured accurately but are subject to calibration error, you can correct them using Google estimates. This in turn can be done in either of two ways: calibration (adding a constant offset) and linear regression. The latter makes allowance for slow shift in atmospheric pressure but may be affected more by inaccuracy in Google altitudes.

If your altitudes have been measured barometrically, some form of Google correction is likely to be the best adjustment, choosing regression for routes of some length, but preferring calibration for short tracks such as MTB downhills.

If there are erroneous altitudes in your track (owing to a device misfunction or poor satellite reception), it is best to delete them before invoking Google correction since the errors may have a distorting effect.

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

Load one track into routemeaster and then load the other as a new segment (cogwheel button). Edit the two segments so that they join naturally; if you need to change their order, you can swap them from the Segment Props button (broken cogwheel). When you’re happy, combine the segments (again from Segment Props).

The routes may have differently calibrated altitudes. It’s best to make them consistent before combining them.

The times of the route you finally produce will probably be out of sequence, in which case they will be discarded when you save it. You may find it useful to delete the times in one segment (Segment Props) so that the remaining times are in sequence; this will save having them discarded.

Click on the download button. routemaster performs some repairs, recommends others, and gives you the choice of saving as GPX/TCX/FIT. (See below for their relative merits.)

If you download as FIT from Firefox you are likely to get a message asking what to do with the file – whether to save it or open it. Check the “save” option. To tell Firefox to do this automatically from now on, visit the URL of a FIT file and you will be given the same message, but with a further tick-box requesting to save automatically in future.

Link to a FIT file.

To see the tool in action, look at the Monte Tondo circuit we rode in Tuscany in June 2017. 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. Click on the camera icon to see photos; hit [enlarge] to view them at full size. The arrow keys navigate forwards and backwards between them, and [return] takes you back to the track.

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

To see a metaindex go to our Tyrrhenia metaindex, which works much like an index but one stage higher.

If a track is on the web, you can invoke routemaster through a URL which brings it up showing the track in question. The URL has the form

https://www.routemaster.app/?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

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

This is more useful for HTML links than for direct typing into a browser. More details are provided below.

If you include spaces or other punctuation not suitable for URLs

Set up Google Maps to show directions from one place to another. You will get a page like this. Copy the URL from the URL bar and enter it into the URL load in Routemaster – either from the start page or by requesting ‘Load new route’ under the cogwheel menu.

The implementation isn’t as straightforward as you might hope. routemaster can’t access the Google Maps page; it has to break out the parameters of the request from the URL, and then send a similar request to the Google Directions service, whose software is (or was when I tested) separate from Google Maps, sometimes giving a different result.

You may create a routemaster account and log in to it: this brings some small benefits and no appreciable drawbacks; you’re recommended to do so if you use routemaster at all often.

The benefits are these:

Once you’ve logged on, you should stay logged on for 3 months, so you won’t have to keep retyping your password. The long duration is justified by the lack of serious consequences if anyone gets onto your account. If you log on from a shared computer, don’t forget to log out when you finish.

Adding new languages is comparatively easy. If you’d like to help, take a look at the list of English prompts and at its corresponding French translation. If you can produce a similar version in a third language, I can extend routemaster’s range. At present I can only accept Romance languages and German because I need to be able to modify the translations myself.

Si mon français n’est pas parfaitement idiomatique, vous pouvez m’aider en m’envoyant par e-mail une amélioration. Il y a un lien e-mail pratique sous le bouton « compte ».

Email me at colin.champion@routemaster.app. I don’t guarantee to make changes but I’m happy to hear from you. If you have an account, there’s a friendly email link under user preferences.

If you encounter a bug, it may be that I’ve been making recent changes. It might be worth performing a hard reload ([shift][cmd]r on an Apple) to make sure you aren’t running a version whose components are out of step.

• recent updates     • things Routemaster is not for     • input formats     • output formats     • going to the startpoint of a track     • viewing photos     • rectifying post-prandial notches     • superimposed tracks     • editing     • timings     • route descriptions     • putting a version on the web     • getting an overview of a set of tracks     • uploading and downloading     • getting to routemaster painlessly     • url parameters     • understanding what’s happening     • advance warning of turnpoints     • centimetre resolution    

Just a word of advice: I would prefer you to use Firefox than Chrome. Google use their dominance to seek to impose their own objectives on the internet (eg. trying to make plain http unusable); this leads to compatibility problems. I do my best to maintain routemaster under Chrome in spite of their obstacles, but most of my testing is under Firefox.

GPX is unsatisfactory because it provides no notation for courses seen as sequences of points, some of which are merely passed through and others of which have turn instructions. It is possible to store turn instructions in GPX files after a fashion (which routemaster does), but importing devices are unlikely to make correct use of them (and Garmin devices do not do so). In any case the ‘type’ of a GPX waypoint, like its name, is a character string, giving it no capabilities in combination with the name which the name would not possess alone. Other formats limit types to a closed set whose elements can be associated with icons and translations by importing software.

TCX is much better. The mapping from course points to positions along a route is a little vexed, and the list of course point types could have been better chosen, but these are minor cavils. Both GPX and TCX are rather bloated: in TCX I have to write

    <Position>
      <LatitudeDegrees>51.88266</LatitudeDegrees>
      <LongitudeDegrees>-2.08383</LongitudeDegrees>
    </Position>

when

lat="51.88266" long="-2.08383"

is all that should be needed.

FIT is compact and stores course points effectively, allowing a larger set of types than TCX. However it is binary rather than text, opaque, almost boundless in extent, and badly documented. It should not be understood as a route format, but as a format for fusing inputs from training sensors amongst which GPS readings have no particular prominence. In practice it doesn’t allow extensions in the same way as GPX and TCX, so some of the information routemaster associates with a course (eg. the description and photos) will be lost on transfer to FIT.

I can’t say I’m enamoured with the FIT notation for coursepoints. It requires their position within a track to be determined from their timestamps (since they do not need to come in order, and all other relavent fields are either optional or forbidden). But timestamps for courses are purely suppositional (and specified to a precision of just a second). Moreover a coursepoint may be given a timestamp intermediate between two records. In this case it may be give map coordinates (Garmin Connect does this), so the coursepoint becomes an additional waypoint; but it cannot be given an altitude, so the receiving software needs to be ready to deal with incomplete geographical information. This creates an unnecessary conundrum for programmers.

RTE is an XML format along the same lines as GPX and TCX. It treats turn instructions in a sensible way, namely as attributes of points along a track rather than as defining a separate class of point. Therefore positioning the turn instructions does not pose any problems to the importing software.

It is also relatively compact, being about half the size of the corresponding GPX and 5 times smaller than the corresponding TCX. If only someone else would provide independent support it might be quite nice.

It has a documentation page.

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.

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 props’ and select ‘delete segment altitudes’. Then combine the segments again (under ‘Route props’). You can request Google to fill in the missing altitudes from the cogwheel button, or wait until you are ready to download at which point you will be prompted.

If one part of a route is superimposed on another (for instance with an out-and-back route) it can be hard to select a point on the desired stretch. But if you open the altitude profile and click on that, there is no ambiguity.

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.

GPS units record times for each position. If you reorder the segments in a track the times will not be in sequence, and Routemaster will delete them prior to downloading for fear that they cause problems if you use the track for navigation. To avoid this you can delete some of the times yourself in such a way that the remainder are in sequence. Do this by deleting times for individual segments under the ‘segment properties’ button.

You may provide a description for your route, which will be displayed under the cogwheel button and may be edited. It will be stored in an extension field of the TCX/GPX file, but will also be accepted on input from the GPX desc field. It will not be preserved in FIT output.

You may enter the text of your description into an edit window. A limited number of HTML formatting options are available.

Links should be in track reference form (described in the following paragraph). They may be preceded by a ‘+’ sign, eg.

  <a href="+othertrack.gpx">some text</a>

in which case clicking on the link will lead to the specified track being added as a new segment to the track being viewed.

The quotation marks for the href may be omitted since the entire link is already a character string within the XML document. Eg.

  <a href=+othertrack.gpx>some text</a>

The link may also be to a standard web page (i.e. not a GPS track), in which case it will be handled in the normal way.

When a GPS track refers to another track, it does so in ‘track reference format’. This allows 3 forms of reference.

Firstly, the entire absolute URL for viewing the track may be provided, eg.

  href="https://www.routemaster.app/?track=https://www.masterlyinactivity.com/routemaster/routes/cotswolds/Notgrove.gpx"

Secondly, you can limit yourself to the URL of the track itself, eg.

  href="https://www.masterlyinactivity.com/routemaster/routes/cotswolds/Notgrove.gpx"

Finally, and most conveniently, if you are referring to a track close in the directory structure to the place the reference occurs, you can use a relative URL, eg.

  href="Notgrove.gpx"

Relative URLs are expanded to absolute URLs using the location of the track containing the reference. If the track is to be displayed, it is expanded to a routemaster invocation by adding the relevant part of the URL of the current browser location.

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

You cannot link to a route which has missing altitudes (and therefore cannot link to a Google Maps direction page as a Routemaster track) because if you do so the altitudes will be recomputed every time someone follows the link. Instead you must put the route through Routemaster so that altitudes are included.

Even if altitudes are present, 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.

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.

If you buy an MTB guide book there will usually be an associated set of GPS tracks obtainable in some way. Put them in a directory and load them in bulk (ie. go through the file load menu, selecting all of them). You will see a composite track in which each segment is one of the routes in the book. Go to the cogwheel menu and change the route title to something more appropriate; then hit ‘Save track as route index’. If you now load the index you have created you will see the tracks in a more attractive way.

However if you click on them you will find that the links to individual tracks do not work. Upload the directory of tracks to your website (keeping its location secret if the tracks are not your own), and do a global find and replace on the route index to correct the URLs in the TrackLinks. Now if you open the index in Routemaster you will see the index correctly and the links will work (see below).

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.

It’s no longer painful to type in the routemaster URL: ‘routemaster.app’ in the URL bar should be all that’s needed. But it still has a cool bookmark icon.

If a track is on the web, you can invoke routemaster through a URL which brings it up showing the track in question. The URL has the form

https://www.routemaster.app/?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

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

You may specify some display options as part of the URL by adding a string of the form “&mode=opts” where opts is a character sequence in which the meaning of the characters is as follows:

So you might write:

https://www.routemaster.app/?track=https://developer.garmin.com/downloads/connect-api/sample_file.tcx&mode=fz

TCX and GPX files are editable text. If you load one in a text editor you will see some blurb at the beginning, then a list of points, then some additional information at the end, some of which has been added by Routemaster. You may want to change some of this information – indeed, for some purposes you have to. It isn’t difficult. Just make sure the ‘<’, ‘>’ and ‘"’ delimiters are opened and closed correctly.

A feature routemaster does not offer (although some other tools do) is advance warning of labelled waypoints. The idea has occurred to me, and been suggested, but upon thought I’ve rejected it.

So I’ve come to the view that routemaster should work within the limitations of GPS devices and formats as they are rather than bypass them in a way which might make lead to compatibility problems. You can of course manually insert warnings.

See above.

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 thus gets an initial reward.

The next step is to load a track in the normal way. When you want to add a photo at a particular position, select the appropriate waypoint by clicking near it and using the arrow keys for fine adjustment, then click on the camera button. You will be invited to load the photo list, which can be done from the URL or from your own computer (but the first is better). Once the list has been loaded into Routemaster you will be prompted to enter the name of an image from the list. The corresponding photo will then be shown at the chosen waypoint. 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 work from this version of the track the photos you’ve entered will be retained, and you will not need to specify the photo list again when adding new photos if you entered it as a URL. (If you loaded it from a file, you may want to change it to the URL using a text editor.)

I wish I could say that this was plain sailing but I occasionally have difficulties myself. The process is gradually becoming easier.

In case of difficulty, in Firefox, open the web console (Tools > Web Developer > Web console): you may see some informative messages. In Chrome it’s View > Developer > Developer tools. 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 be connected by dashed lines as if they were parts of a single route.

Click on ‘Save track as route index’ (under the cogwheel) and the index file will appear on your computer. The new track will 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 in the ‘tracklink ’field of each route. A global replace within an editor will correct it for the web. It should be in track reference format, eg.

    <tracklink href="Upcote.gpx"/>

You can edit this tag to add a ‘mode’ attribute, specifying the mode with which the track should be loaded (see url parameters). Eg.

    <tracklink href="Upcote.gpx" mode="p"/>

which requests the Upcote track to be loaded displaying its altitude profile.

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"/>

to a track stored in .rte format. The uri should again be in track reference format. For GPX and TCX you need to add the index tag to the extensions after the track points. You wwrite

    <Index href="uri"/>

for TCX or

    <index href="uri"/>

for GPX.

When adding a route to an index, you may add another index instead of a single route. This may be useful if you have a cluster of routes which it makes sense to look at alongside other individual ones. The Sperane tracks in our L. Garda (east) index are an example.

You can add new tracks to an index at any time, or if you’ve changed a track on the web, you can update it as a single operation.

When you select the option to save a track as a route index, it will be automatically optimised more aggressively, as is suitable for indexes (in which you don’t want to keep as many points). You can bypass this optimisation by shift-clicking on the pseudo-link ‘Save track as route index’. I do this for an index such as our Sperane one which is the same size as a normal route rather than a normal index, and is itself incorporated within an index (in this case our East Garda one).

To create a metaindex, load the first index into routemaster. Click on ‘Download index as metaindex’ (under the cogwheel) and the index file will appear on your computer. The new track will display as a metaindex. You can now add further indexes to your heart’s content and save the result using the download button.

You can add new indexes to a metaindex at any time, or if you’ve changed an index on the web, you can update it as a single operation.

• data acquisition     • data transfer     • optimisation     • altitude smoothing     • actions list     • TCX and GPX     • Courses and tracks     • mapping     • photos     • altitudes     • downloading     • colouring tracks     • known problems     • 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.

https://www.routemaster.app/?track=https://www.masterlyinactivity.com/routemaster/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 segment info menu (the broken 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 (as was reported on the Garmin support forum when this existed). 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.

The benefit of dynamic programming is to reduce the work factor from exponential (for a brute-force algorithm) to quadratic. Quadratic is still expensive if there are a lot of points. To avoid excessive cost, it may be useful to perform more than one optimisation pass, reducing the number of points by a significant ratio each time. Also, if you don’t have a natural limit on the legal point separation, it makes the optimisation faster (in fact linear rather than quadratic) if you impose a limit, even if it’s larger than is likely to make any difference. If a limit is being imposed for this purpose, it should be a maximum distance along the route rather than as the crow flies; an additional parameter (maxjump) has been added for this purpose.

Both of these strategies are used when a raw (unoptimised) track is added to an index. The optimisation takes place in two passes, the first of which is similar to the optimisation for a normal display, and the second of which reduces to the final size. A maxjump is imposed to bound the cost of the second iteration (though this isn’t much anyway).

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

The optimisation selects a smaller set of points to retain. I used formerly to discard all the intermediate points, but now I try to improve the accuracy of altitudes by smoothing them. If three successive points A, B and C are being retained, then the altitude at B is reestimated by fitting a curve to all altitudes after A and before C. The fit is weighted, with the weights for the points governed by a Gaussian curve with standard deviation around 14m. A linear or quadratic fit is selected according to an ad hoc criterion.

This operation is messier than might appear because I have to keep track of the altitudes before reestimation so as to be able to revert to them if the user hits the ‘undo’ button.

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.

My device (a Garmin Edge 500) accepts only TCX tracks; some devices accept GPX (and maybe only GPX). routemaster outputs either format. I can validate my TCX output by using it. I can partially validate the GPX by reading it in (in routemaster and other tools) but cannot be sure that any given GPS device will handle it correctly.

If you learn of any features which don’t work properly on GPX tracks, I’d be delighted if you let me know.

I use Google Maps v3 API. I don’t have any plans to extend this to other map sources. However I like openstreetmap and would adopt it if it was easier to do so.

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.

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.

This topic has cost me more effort than the results seem to merit. When an index is displayed I want to ensure that nearby (and possibly overlapping) tracks are shown in easily distinguishable colours, whereas it doesn’t matter if tracks which are a long way apart have similar colours.

My method is to set up a proximity matrix recording how close each track is to each other track. I generate a set of colours by subdividing the colour cube, and associate them with the tracks in order. Then (since no closed-form optimisation algorithm seems to be available) I loop through the pairs interchanging colours if it improves a certain objective function. I stop when no further changes are accepted, or when a certain number of iterations have taken place.

The proximity of two tracks is determined by comparing every line segment of the first with every line segment of the second: the metric I use is the ‘distance between two line segments’ (which, being the minimum distance rather than the average isn’t quite right).

The total work in computing proximities is therefore quadratic in the number of points. In order to keep it from getting out of hand I downsample each track by a factor of 10.

About 250 lines of code are devoted to this task, and they cost me a lot of thought without my having arrived at a very appealing algorithm; and I’m not convinced that the results are entirely satisfactory.

There are 2 areas in which Google’s behaviour does not match what routemaster needs.

The first concerns response to requests to open an info window. If the request comes before the Maps API has completed its background tasks, it has no effect. An example is if you click on the camera symbol indicating a photo at a waypoint. This brings up a thumbnail. If you now click on a similar icon at another waypoint, the first info window is closed and a request is sent to open another one, but the request always fails. This behaviour is undocumented; there is no return code; I have no idea how to test for whether background tasks have been completed or how to wait for them to complete. So you just have to keep on clicking until you get what you want.

The second problem concerns URLs to Google Maps directions pages. I try to get the same result from the Google directions API as has been given by the Maps page, but the services are different and the results may not be consistent. There are different algorithms for parsing lat/long values and for disambiguating place names, and the via qualifier doesn’t work (this stackoverflow question refers). So the result may not be what it should be.

There are other tools for converting Maps directions to tracks (e.g. mapstogpx), but I haven’t determined whether they are more successful. Some of them use server processing which is less visible than the Javascript sent to the browser.

My understanding of FIT format derives from a number of sources:

In the course of learning about FIT I wrote a simple C program hex.c to walk through a FIT file, printing out definitions, records, and course points. I include it at the end of this page.

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

I fixed a number of bugs, updated for the latest version of pixlib.js, and at last added a graphical presentation of gradient to the waypoint info. I added the ‘n’, ‘o’ and ‘p’ options and the ‘mode’ attribute of tracklinks. I added altitude smoothing and fairly thoroughly debugged the user preferences, adding decimetre precision as suitable for hillclimbs. I also added the forward distance in wpinfo.

I rewrote listhues to avoid hues in the green–cyan range which clash with the background green on Google maps. And I added titles to <index> tags.

I had a rude shock when I discovered that sometimes my new Garmin generated so many nearly coincident points that my optimisation algorithm appeared to freeze. I added a prefilter which squeezes out points which are too close. An optimisation algorithm which handles the case gracefully would be better.

I added a rudimentary csv input option. I can improve it if I have need.

A sizeable update has taken place. Watch out for bugs! This is mostly driven by finding out that FIT is the only file import format supported by current Garmins. I have given it full support (for output as well as input) in routemaster; I added .rte format, and restructured all the code relating to the storage of indexes.

Removed a bug from the segment duplication. Added compressed timestamp FIT files. Fixed the file server to get faster response.

Added the ability to duplicate a segment.

Added registration/login and made routemaster.app the default version.

I added the ability to incorporate HTML tags into route descriptions and the option to generate an HTML list of routes. I also added the ‘mode’ parameter to URL invocations. I made the index optimisation slightly more aggressive and allowed it to be bypassed by shift-clicking rather than through a confusing prompt.

I broke out the text strings into a separate file while merging most of the rest into a single huge routemaster.js. I fixed some bugs but probably introduced some more.

I added a link to the relevant doc to the altitude adjustment menu and to the fountain pen prompt.

I added the option to correct altitudes by linear regression against Google, and improved the description of the altitude options.

I fixed a bug in undoing a combine and another in the bulk load if the files were returned out of order. I improved the display of the altitude profile in cases when there were points without altitudes (though it could still be better) and made allowance for faulty tracks in which points have the same times.

I also made it possible to include line breaks (and < and > signs) in the description.

I made routemastercm, wrote some initial code for registration/logging in,and fixed a bug in interpolating at the end of a segment.

I added options to show/hide the index arrows.

I slightly improved the placement of arrows on index tracks. I did some optimisation of the load process, substituting HTML canvases for the button icons and rolling up the third-party scripts into a single file ‘routemasterextra.js’. In the process I noticed and corrected a bug (actually a misunderstanding) which led to the altitude profile not being as sharp as it should be on high-res screens.

Add rudimentary kml support. (I encountered a KML track on the web, and my favoured conversion utility had become cumbersome to use.) No guarantees. I also fixed a bug affecting the display when dragging a waypoint and implemented the optimisation of precomputing the distances between successive points (which are used lots of times, so may as well be computed only once). And there was another, even more minor, bug affecting the title of fit tracks.

A very minor change to pick up the latest version of pixlib.js, which provides a menu button for enlarged images.

Two changes in Oct. The first, which required some rethinking of the logic, was to allow subindexes to be included in indexes. The second is to propagate up the identity of the picture page and to link it from the cogwheel menu for routes and indexes, and from the info box for each index in metaindexes.

The first change wasn’t carried to completion: it is not yet possible to update a subindex. But that’s easy to fix.

In Nov I rewrote the squash() function to avoid use of Object.assign() because the obsolete Safari on my laptop doesn’t seem to like it. At the same time I fixed a couple of what seemed to be bugs.

The first of these was in response to a request from a user (I have users!). The second violates the tcx standard, but I’ve loaded routes successfully which had text longer than 10 chars. (It would take a genius to make problems out of this, but Garmin employ geniuses.)

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, which may 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.

[June 2019.] Wikiloc and mygpsfiles.com have moved away from the Google Maps API because of the prohibitive charges. I’m not being hit myself because I don’t have enough (indeed any) users (besides myself). But I’d be reluctant to move. Other services may stay free only for as long as it takes to establish a base of dependent users. Those which are open source as a matter of policy will presumably stay that way, but providing, updating and storing maps and satellite images costs money and there’s no reason why the providers shouldn’t charge accordingly. The open source alternatives, if they continue to exist, may be obviously second class.

This is not to excuse Google’s prices. I suspect they overcharge bulk customers to cross-subsidise small-scale users such as myself. But this makes no sense. They’re a bit boxed into a corner by the failure of microcharging to establish itself.

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    • Garmin Connect FIT (New Files)    • Garmin Connect FIT (Courses)   

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

GPS visualiser track (no alts)    screwy mbwales track    Google directions    kml track   

However these relate to the ability to load different formats, and therefore to parts of the software that seldom change nowadays.

A more significant concern is to make sure that routemaster is compatible with different browsers. I tested it in Nov ’19 in Firefox, Chrome, Opera and Safari. It only partially worked in the last of these, but my version dates from 2014 and I suspect the problem lies in the browser not being supported by current versions of the Google maps API. I removed a call to Object.assign() which I had noticed caused problems in pix.js.

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

I originally issued routemaster (in 2015) under an MIT licence; then I thought better of it and held back all but routemasterlib.js; and now I have withdrawn the open source licence from the entire tool.

This is for two reasons. It is harder to maintain open source software than software for restricted use (and routemaster is very hard to maintain); and routemaster gives access to paid services (Google Maps API) and its public distribution could be abused to bypass the charging mechanisms. Neither of these considerations applies to pixlib.js, which remains open source.

[Why is it harder to maintain open source software? Because every defined interface needs to be validated, not just the tool as a whole.]

You are of course free to use routemaster as supplied, and to consult its source code for information (nothing is hidden).

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 has several faults.

When I’d lost patience with the tendency of maps to blank out I bought an Edge 130+ as a replacement. It appealed to me because it made use of additional satellite networks and wasn’t overloaded with unwanted new features. But I’m not entirely happy.

• routemaster.js     • routemaster.en.js     • routemaster.fr.js    

• domadd     • domcreate     • prettynum     • shortenname     • abbreviate     • infocloser     • function     • resizer     • dotpath     • linepath     • arfunc     • greyout     • blackout     • bulkout     • redrawbtn     • enterFullscreen     • exitFullscreen     • findimg     • unsavedmsg     • selpoint     • distrec     • segdist     • highlight     • unhighlight     • eventually     • getbtnpos     • walkto     • keystroke     • shiftkey     • undraw     • redraw     • drawprofile     • draw     • disconnect     • reconnect     • connect     • redrawconnect     • drawsel     • checklostedits     • genpage     • parselist     • getlist     • render     • genbutton     • acfactory     • addload     • refresh     • renfactory     • wpinfo     • rellist     • dl     • cofactory     • canceldl     • indexdl     • confirmeddl     • semiconfirmeddl     • reconfirmeddl     • optimaccept     • optimparms     • pen2detail     • optimmerge     • optimwork     • dgen     • posit     • vender     • unshadow     • accept     • redelta     • retitle     • respond     • restars     • routeinfo     • calwork     • manualcal     • googlecalwork     • googlecal     • googlereg     • googleadd     • help     • wpdelwork     • wpdel     • revsegwork     • revseg     • dupsegwork     • dupseg     • insert     • inswp     • draggit     • undraggit     • seginfo     • altinfo     • deltimes     • interpol     • extrapts     • combine1     • combinework     • combiner     • combine     • combinef     • combineb     • uncombine     • setalt     • delalts     • labelprompt     • editlabel     • unlabel     • photoprompt     • photoedit     • phadvance     • phretreat     • next     • prev     • backtogps     • display     • dodisplay     • imgwalk     • simulate     • phinfo     • snipwork     • snip     • xferwpwork     • xferwp     • binwork     • discard     • undiscard     • swapsegwork     • swapseg     • actiontype     • done     • donesomething     • optimswap     • refreshswap     • undo     • confirmedundo     • move     • redo     • confirmedredo     • actionname     • optimise     • decimate     • distptseg     • distsegseg     • hexify     • hexdigit     • dehexify     • numerate     • d3     • dsseg     • segprox     • assigncolours     • cdif     • listhues     • extendhues     • genshades     • snipcolour     • shadeofhue     • segbounds     • deltify     • recurse     • getprox     • ascify     • xmlify     • profilemaptype     • getalts     • doelevations     • getbounds     • promoteprops     • loadtrack     • callindexify     • flatten     • genarrows     • parsedesc     • parsehtml     • loaderfactory     • trackref     • pluralise     • caps     • xmlfloat     • isvaliddate     • pttype     • addlabel     • routetype     • interp     • bearing     • getpt     • getprops     • getrteprops     • setsegpos     • normalise     • readtcx     • readgpx     • readrte     • readkml     • getlatlong     • getllpt     • readgoogle     • readcsv     • readfit     • maketext     • readfitvalue     • readfitangle     • dist     • angle     • gettags     • writegps     • gentag     • gentagattr     • gensrc     • gentypedtag     • geninfotag     • genimgtag     • genurl     • genindex     • addp     • addpos     • genoptim     • gpxp     • protectpos     • writerteprops     • writerte     • writefit     • fitinject     • fitinject2     • fitinject4     • fitinjectangle     • checksum     • getstats     • getmetastats     • unspace     • parsestats     • diststring     • kmstring     • formatstats     • prettystats     • extendphoto     • gatherpix     • thumbpix     • writeindex     • indexify     • getgallery     • genpixpage     • gendesc     • routestats     • squash     • clone     • absuri     • underline     • textdiv     • highdiv     • delfactory     • updfactory     • genpicimg     • serve     • scrolltype     • scroller     • btnicon     • newcanvas     • buttons     • cloneCanvas     • buttoncell     • textcell     • appendrow     • genlink     • genindexlink     • genspan     • genclickfn     • logout     • acmenu     • logoutfactory     • doprefs     • bugreport     • acfollowon     • greyit     • emailvalidate     • pwdvalidate     • otkvalidate     • loggedin     • phpresponse     • formresponse     • blurbdiv     • rmhelpdiv     • addbull     • genpixlink     • cogwheelmenu     • addloadfactory     • dlfactory     • arfactory     • doalts     • deltimesfactory     • seginfodiv     • swapsegfactory     • optimfactory     • altinfodiv     • doclink     • walktodiv     • displayfactory     • pheditfactory     • phinfofactory     • tabulate     • highfactory     • addcell     • listroutes     • wpinfodiv     • altfactory     • wpfactory     • titlediv     • titlefactory     • textpromptcleanup     • speaklang     • textprompt     • drawicon     • drawlabel     • genrect     • iconsel     • iconfactory     • flagcanvas     • cclick     • pclick     • returnkey     • genldiv     • tdivadd     • intise     • starsline     • createfunc     • starfactory     • profiletype     • drawxcur     • drawpro     • toggleprofile     • point2LatLng     • filedialogue     • telltale     • wipetale     • zonktale

var segments=[],selected=null,actions=[],nactions=0,dragging=0 ; 
var sel = { marker:null, orientation: null } ; 
// sel contains information about the marker of the selection point
var shifted=null,unsavedchanges=[],pendingdl=null ; 
var mapdiv,pro=null,routeprops,map=null,imgdiv=null,imginfo,showarrows=1 ;
var scissorsbtn,undobtn,redobtn,penbtn,setbtn=null,dlbtn,segbtn,wpbtn ; 
var acbtn=null,photobtn=null ; 
var promotable = { list:0 , title:1 , desc:2 , origin:3 , index:4 , srcid:5 , 
                   stars:6 , info:7 , gallery:8 , stats:9 , tlink:10 ,
                   filename:11 , tmode:12 ,
                   photo:20 , smallphoto:21
                 } ; 

var defparms  = {tol:15,maxsep:100,wppenalty:700,vweight:1} ;
var intparms  = {tol:15,maxsep:null,wppenalty:700,vweight:0,maxjump:200} ;
var indparms  = {tol:0,maxsep:null,wppenalty:100000,vweight:0,maxjump:5000} ;
var metaparms = {tol:0,maxsep:null,wppenalty:100000000,vweight:0,maxjump:10000};
var parser = new DOMParser() ;

function domadd(div,txt) 
{ if(typeof txt=='string'||typeof txt=='number') 
    div.appendChild(document.createTextNode(txt)) ; 
  else if(txt) div.appendChild(txt) ; 
}
function domcreate(type,txt,p1,p2) 
{ var div = document.createElement(type) ; 
  if(p1&&p2) div.setAttribute(p1,p2) ; 
  if(txt) domadd(div,txt) ; 
  return div ; 
}
function prettynum(num,opt,sfx)
{ var s , span = domcreate('span') , len ; 
  num = num.toFixed(0) ;
  len = num.length ; 
  if(len>3&&opt) 
  { s = domcreate('span',num.substring(0,len-3),'style','padding-right:1.5px') ;
    span.appendChild(s) ; 
    num = num.substring(len-3) ; 
  }
  domadd(span,num) ; 
  if(sfx) span.appendChild(domcreate('span',sfx,'style','padding-left:1.5px')) ;
  return span ; 
}
function shortenname(uri) 
{ if(!uri) return uri ; 
  var i = uri.lastIndexOf('/') ;
  if(i<0) return uri ; else return uri.substring(1+i) ; 
}
function abbreviate(name)
{ if(!name) return [ name,null ] ;
  name = shortenname(name) ; 
  var len=name.length , extn=len>0?name.substring(len-4).toLowerCase():null ;
  if(trackextns.indexOf(extn)>=0) return [ name.substring(0,len-4) , extn ] ; 
  else return [ name , extn ] ; 
}
function infocloser(obj,opt)
{ var otype = obj.type ; 
  if(opt) obj.handle.close() ;
  obj.handle = obj.type = null ;
  if(otype=='highlight') unhighlight() ; 
  else if(otype=='phinfo') walkto(selected[0],selected[1]) ; 
  else if(otype=='getlist'&&opt==0&&imginfo.status=='?') // exists but may be
  { imginfo.status = 'ready' ; photoprompt() ; }         // obsolete
  return otype ; 
}
var infowindow = 
{ handle: null , 
  type: null , 
  closer: null ,
  open: function(s,pos,type)
  { this.handle = new google.maps.InfoWindow({content:s,position:pos}) ;
    this.handle.open(map) ; 
    // vv this is invoked by clicking on the 'x'
    google.maps.event.addListener(this.handle,'closeclick',
                                  function() { infocloser(infowindow,0) ; } ) ;
    this.type = type ; 
  } , 
  close: function() // this is invoked externally (by infowindow.close())
  { var c = this.closer ;
    if(c) { this.closer = null ; c() ; return null ; }
    else if(this.handle==null) return null ; 
    else return infocloser(this,1) ; 
  } 
} ; 
/* -------------------------------------------------------------------------- */

function resizer()
{ if(imgdiv) return ;
  var isfullscreen = queryfullscreen() , fse ; 
  if(mapparent==null||body==null) 
    mapparent = body = document.getElementsByTagName("body")[0] ;
  if(isfullscreen>0&&!resizer.wasfullscreen&&mapparent==body&&pro!=null)
  { fse = document.fullscreenElement || document.mozFullScreenElement
       || document.webkitFullscreenElement || document.msFullscreenElement  ;
    if(fse&&fse!=body)
    { mapparent = fse ; 
      if(pro)
      { body.removeChild(pro.prodiv) ; fse.appendChild(pro.prodiv) ; 
        body.removeChild(pro.curdiv) ; fse.appendChild(pro.curdiv) ; 
      }
      window.removeEventListener('resize',resizer) ; 
    }
  }
  resizer.wasfullscreen = isfullscreen ;
}
/* -------------------------------------------------------------------------- */

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(seg,start,end,colour,width,zindex)
{ var i , pts=seg.pts , len ;
  if(!start||start<0) start = 0 ; 
  if(end&&end>0) len = end - start ; else len = pts.length - start ; 
  if(!width) width = 2 ; 
  this.path = new Array(len) ; 
  for(i=0;i<len;i++) this.path[i] = pts[start+i].pos ;
  this.clickable = (segments[0].level>0) ; 
  this.geodesic = true ;
  this.strokeColor = colour ;
  this.strokeOpacity = 1.0 ;
  this.strokeWeight = width ;
  if(zindex) this.zIndex = zindex ; else this.zIndex = 0 ; 
  if(showarrows) this.icons = seg.arrows ;
}
function arfunc(opt)
{ infowindow.close() ; 
  var i,seg ; 
  for(i=0;i<segments[0].pts.length;i++) 
  { seg = segments[0].pts[i] ;
    seg.line.setOptions({icons:opt?seg.arrows:null}) ;
  }
  showarrows = 1 - showarrows ; 
}
/* -------------------------------------------------------------------------- */
/*                             UTILITY FUNCTIONS                              */
/* --------------------------- button handlers  ----------------------------- */

function greyout(btn) { redrawbtn(btn,1) ; }
function blackout(btn) { redrawbtn(btn,0) ; }
function bulkout(black)
{ var f ; 
  if(black) f = blackout ; else f = greyout ; 
  f(segbtn) ;
  f(wpbtn) ;
  if(black==0||selected[1]) f(scissorsbtn) ;
  f(penbtn) ;
  f(photobtn) ;
  if(black==0||nactions>1) f(undobtn) ;
  if(black==0||nactions<actions.length) f(redobtn) ;
  f(dlbtn) ;
  if(acbtn) f(acbtn) ;
}
function redrawbtn(btn,opt) // 0<->black  1<->grey  -1<->redraw as is
{ if((segments[0].level&&(btn.index==2||btn.index==4||btn.index==5))) return ; 
  var i ; 
  for(i=0;i<btn.ui.childNodes.length;i++)
  { node = btn.ui.childNodes[i] ;
    if(node.tagName=='CANVAS') btn.ui.removeChild(node) ; 
  }
  if(opt==0||(opt<0&&btn.active)) 
  { btn.ui.appendChild(btn.blackimg) ; 
    btn.ui.onclick = btn.handler ;
    btn.ui.style.cursor = 'pointer' ; 
    btn.ui.title = btn.blacktitle ; 
    btn.active = 1 ; 
  }
  else
  { btn.ui.appendChild(btn.greyimg) ; 
    btn.ui.onclick = null ;
    btn.ui.style.cursor = 'default' ; 
    btn.ui.title = btn.greytitle ; 
    btn.active = 0 ; 
  }
}
/* ------------------------ 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,j,ll ; 
  for(i=0;i<imginfo.sect.length;i++) 
    for(ll=imginfo.sect[i].list,j=0;j<ll.length;j++)
      if(ll[j].name==id) return [i,j] ; 
  return null ; 
}
/* ------------------- message warning of unsaved changes ------------------- */

function unsavedmsg(ok)
{ var msg , len = unsavedchanges.length , i ; 
  if(len==0) return null ; 
  msg = pluralise(L.unsavedchanges,len) ; 
  if(len<=3) for(i=0;i<len;i++)
     msg += (i?',':' (') + unsavedchanges[i] + (i==len-1?')':'') ;
  msg += '\n' + L.ifyouhit + ' [' + (ok?L.ok:L.leavepage) + '] ' ; 
  return msg + pluralise(L.willbelost,len) 
}
/* --------------- selpoint: choose the clicked waypoint  ------------------- */

function selpoint(event)
{ var i,r,s0,s1,scale,flag=0 ; 
  if(dragging) return ; 

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

  if(segments[0].level==0&&!flag&&shifted) // insert waypoint forwards in seg
  { s0 = selected[0] ;
    s1 = segments[s0].pts.length ;
    insert(s0,s1,1) ;
    segments[s0].pts[s1].setpos(event.latLng) ;
    redelta(segments[s0].pts,s1) ;
    redrawconnect(s0,s1) ;
    done(['move',s0,s1,event.latLng,event.latLng,1]) ; 
    drawprofile() ;
    walkto(s0,s1,0) ; 
    return ; 
  }

  // select the closest point
  if(segments[0].level>0) 
  { r = distrec(segments[0],event.latLng) ; s0 = r.ind ; mindist = r.dist ; }
  else for(i=0;i<segments.length;i++) 
  { r = segdist(segments[i].pts,event.latLng) ; 
    if(i==0||r.dist<mindist) { s0 = i ; s1 = r.ind ; mindist = r.dist ; }
  }

  // now we have found the shortest distance from the click to the track and
  // the corresponding waypoint. require the distance to be <60 pixels, 
  // otherwise ignore the click.
  scale = selpoint.clickscale / Math.pow(2,map.getZoom()) ; // metres/pixel
  if(segments[0].level==0&&flag>1) flag = 1 ; 
  if(mindist<60*scale) walkto(s0,s1,flag) ; 
}
function distrec(seg,pos) 
{ var i , r , res ; 
  if(seg.level==0) return segdist(seg.pts,pos) ; 
  for(i=0;i<seg.pts.length;i++)
  { r = distrec(seg.pts[i],pos) ; 
    if(i==0||r.dist<res.dist) res = { ind:i , dist:r.dist } ;
  }
  return res ;
}
function segdist(pts,pos) // closest approach of a segment to a point
{ var i , b = dist(pts[0].pos,pos) , res = { ind:0 , dist:b } , a , h ; 
  for(i=1;i<pts.length;b=a,i++)
  { a = dist(pts[i].pos,pos) ;
    h = distptseg(a,b,pts[i-1].delta) ;
    if(h<res.dist) res = { ind:(i-1)+(b>a) , dist:h } ;
  }
  return res ;
}
/* -------------------------- track highlighter  ---------------------------- */

function highlight(shifted,segno)
{ if(segno||segno==0) selected[0] = segno ; 
  var s0=selected[0],scroll,i,n,s , norpos = { pos:null } ;
  infowindow.close() ;
  recurse(segments[0].pts[s0],'undraw') ; 
  recurse(segments[0].pts[s0],'draw4') ; 
  scroll = highdiv(s0,shifted) ; 
  highlight.scroller = scroll.scroller ;
  recurse(segments[0].pts[s0],'north',norpos) ; 
  infowindow.open(scroll.div,norpos.pos,'highlight') ; 
}
function unhighlight()
{ var s0=selected[0] ;
  recurse(segments[0].pts[s0],'undraw') ; 
  recurse(segments[0].pts[s0],'draw') ; 
  if(highlight.scroller) 
  { clearInterval(highlight.scroller) ; highlight.scroller = null ; }
  if(sel.marker) sel.marker.setMap(null) ; 
  infowindow.handle = sel.marker = null ; 
  selected[0] = -1 ; 
}
function eventually(f)
{ if(map&&map.getBounds()) f() ; 
  else google.maps.event.addListenerOnce(map,'tilesloaded',f) ; 
}
/* ------------------------------- getbtnpos -------------------------------- */

function getbtnpos(btnno)
{ var bounds=map.getBounds(),sw,ne,lat,lon,lam,h ;
  if(segments[0].level==0) h = 144 ; 
  else { h = 64 ; btnno -= 5 ; if(btnno<0) btnno = 0 ; }
  sw = bounds.getSouthWest() ; 
  ne = bounds.getNorthEast() ; 
  lam = 64.0 / window.innerHeight ; 
  lat = lam*ne.lat() + (1-lam)*sw.lat() ; 
  lam = 0.5 + (btnno*32-h)/window.innerWidth ;
  lon = lam*ne.lng() + (1-lam)*sw.lng() ;
  return new google.maps.LatLng(lat,lon) ; 
}
/* --------------------------------- 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) 
{ selected = [ s0,s1 ] ;
  if(segments[0].level) return highlight(shifted) ; 
  var pt = segments[s0].pts[s1] , pos = pt.pos ; 
  if(!flag) flag = 0  ;
  map.panToBounds(new google.maps.LatLngBounds(pos,pos)) ; 
  drawsel(segments[s0].pts,s1) ; 
  if(flag||(!pt.label&&!pt.photo.length)) 
  { if(flag==1) wpinfo(prefs.precision) ; 
    else if(flag==2) seginfo() ; 
    else if(flag==3) highlight(shifted) ; 
    return ; 
  }
  infowindow.open(walktodiv(pt),pos,'walking') ; 
}
/* -------------------------- keystroke handler  ---------------------------- */

function keystroke(e)
{ if(!selected||infowindow.type=='addload'||infowindow.type=='getlist') return ;
  if(infowindow.type=='account') return ; 
  if(dragging==2) return ;
  var s0=selected[0],s1=selected[1],slast,flag ;

  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&&segments[0].level==0) // down arrow
  { map.panTo(segments[s0].pts[s1].pos) ; return ; } 

  if(e.keyCode==32) // space - no infowindow.close()
  { e.preventDefault() ; 
    if(dragging) undraggit() ; else if(segments[0].level==0&&s0>=0) draggit(0) ; 
    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(segments[0].level) 
  { if(e.keyCode==70) enterfullscreen() ; 
    if(flag!=3||(e.keyCode!=39&&e.keyCode!=37&&e.keyCode!=8&&e.keyCode!=46)) 
      return ; 
  }

  if(dragging) return ; 

  if(e.keyCode==8||e.keyCode==46) // delete/backspace
  { e.preventDefault() ; 
    if(!e.shiftKey)
    { if( segments[0].level==0&&(segments.length>1||segments[0].pts.length>1) ) 
        wpdel() ;
    }
    else if(segments.length>1&&segments[0].level==0) discard() ; 
    else if(segments.length>1) 
    { selected[0] = s0 ; discard() ; selected[0] = -1 ; }
    return ; 
  }
  if(e.keyCode==9) 
  { e.preventDefault() ; inswp(e.shiftKey?-1: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].pts.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].pts.length-1 ; 
    } 
  }
  else return ; 
  if(flag) flag = 1 ; 
  walkto(s0,s1,e.shiftKey?2:flag) ;
}
/* -------------------------- shift key handler  ---------------------------- */

function shiftkey(val) // google maps api has no documented shiftKey field
{ shifted = val ; 
  if(map) map.setOptions
      ({draggableCursor:(val!=0&&segments[0].level==0)?'crosshair':'default'}) ;
  if(val==0&&segments[0].level==0) getalts(segments,200,drawprofile) ;
}
/* --------------------- undraw & redraw segments  -------------------------- */

// needs a comment explaining when to obliterate and when to undraw
function undraw(segment) 
{ segment.line.setMap(null) ; 
  if(segment.clickhandler!=null) 
  { google.maps.event.removeListener(segment.clickhandler) ;
    segment.clickhandler = null ; 
  }
}
function redraw(i) { undraw(segments[i]) ; draw(segments[i]) ; }
function drawprofile() { drawpro(pro,segments,selected) ; } 

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

function draw(segment,width,zindex)
{ var poly = new linepath(segment,-1,0,segment.colour,width) ;
  segment.line = new google.maps.Polyline(poly) ;
  segment.line.setMap(map) ;
  if(!segment.clickhandler) segment.clickhandler = 
    google.maps.event.addListener(segment.line,"click",selpoint) ;
}
/* ----------------------- connect and disconnect segments ------------------ */

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

function connect(i)
{ if(segments[0].level||i<0||i>=segments.length-1) return ; 
  var opos = segments[i].pts[segments[i].pts.length-1].pos ; 
  var npos = segments[i+1].pts[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&&s0>0) reconnect(s0-1) ; 
  if(s1=segments[s0].pts.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(pts,ptno,advance)
{ if(!pts) { pts = segments[selected[0]].pts ; ptno = selected[1] ; }
  var ind,clen=pts.length,pos=pts[ptno].pos,pos2,λ,a,b ;
  if(clen==1) icons.arrow.rotation = 90 ; 
  else
  { if(ptno==clen-1) ind = ptno-1 ; else ind = ptno ;
    icons.arrow.rotation = bearing(pts[ind].pos,pts[ind+1].pos) ;
  }

  if(advance&&ptno<clen-1)
  { pos2 = pts[ptno+1].pos ;
    λ = advance / Math.max(0.1,pts[ptno].delta) ; 
    a = pos.lat() ; 
    b = pos.lng() ; 
    pos = new google.maps.LatLng(a+λ*(pos2.lat()-a),b+λ*(pos2.lng()-b)) ;
  }

  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 ; 
  if(segments[0].level) return ;
  drawxcur(pro,selected) ;

  if(ptno!=0) blackout(scissorsbtn) ; else greyout(scissorsbtn) ;
}
/* -------------------------------------------------------------------------- */
/*                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,mode,resuri) 
{ var div,xmldoc,i ;
  imginfo = { } ; 
  imgdiv = null ; 
  
  if(window.loaded) window.addEventListener("beforeunload",checklostedits) ; 
  else window.onload = function() 
  { window.addEventListener("beforeunload",checklostedits) ; } ;

  while(body.childNodes.length>0) 
    body.removeChild(body.childNodes[body.childNodes.length-1]) ;

  mapdiv = domcreate('div',null,'id','map') ; 
  mapdiv.setAttribute('style','width:100%;height:100%;position:absolute') ; 
  body.appendChild(mapdiv) ;

  if(response==null)
  { div = blurbdiv(resuri) ;
    div.setAttribute('style','font-family:helvetica;padding:0 4px;'); 
    mapdiv.appendChild(div) ; 

    div = filedialogue("load") ;
    div.setAttribute('style','margin:4px;'+
                           'border-top:solid 1px silver;padding-top:4px;'+
                           'border-bottom:solid 1px silver;padding-bottom:2px');
    mapdiv.appendChild(div) ; 

    div = rmhelpdiv(-1) ; 
    div.setAttribute('style','font-family:helvetica;margin:4px;font-size:90%') ;
    mapdiv.appendChild(div) ; 
  }
  else render(response,trackuri,'uri',mode,'load') ; 
}
/* -------------------------------- getlist --------------------------------- */

function parselist(xmltext,imgtype,uri)
{ var xmldoc = parser.parseFromString(xmltext,"application/xml") ;
  imginfo = getphotolist(xmldoc,uri) ; 
  selcat(imginfo) ; 
  imginfo.status = 'ready' ; 
  imginfo.type = imgtype ;
  imginfo.uri = uri ; 
  imginfo.gallery = reluri(uri,imginfo.gallery) ; 
  imginfo.title = getcatval(imginfo.title) ; 
  imginfo.title = ( imginfo.title?imginfo.title:'' ) ; 
}
function getlist(uri,imgtype,r1,r2) 
{ if(uri.substring(uri.length-4).toLowerCase()!='.xml')
  { alert(uri+' '+L.isnotxml) ; 
    imginfo = { status:'failed' } ; 
    return ; 
  }

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

  xhttp.onreadystatechange = function() 
  { var r,i ; 
    if(xhttp.readyState==4)
    { if(photobtn) blackout(photobtn) ;
      if(xhttp.responseText.length==0)
      { pixerr = r = uri ; 
        i = r.lastIndexOf('/') ;
        if(i>=0) r = r.substring(i+1) ; 
        alert(inject(L.unable,r)) ;
        imginfo = { status:'failed' } ; 
        return ; 
      }
      pixerr = null ; 
      if(prefs.pixhits&&0>prefs.pixhits.indexOf(uri))
        prefs.pixhits = addtolist(prefs.pixhits,uri) ; 
      parselist(xhttp.responseText,imgtype,uri) ; 
      if(imgtype=='uriform') photoprompt() ; 
      else if(r1&&r2) r1.smallphoto = gatherpix(r2) ; 
      else if(r1) for(i=0;i<r1.pts.length;i++) 
        r1.pts[i].smallphoto = thumbpix(r1.pts[i]) ; 
    }
  }
  xhttp.open("GET",fileserver+(imgtype=='uriform'?'?get=':'?')+uri,true) ;
  xhttp.send() ;
}
/* --------------------- set up the map and buttons ------------------------- */

// if s0 is 0 then ovr (overwrite option) is always 1
// note that render is invoked repeatedly for a bulk read, so it doesn't need 
// to do a bulk render: it renders ONE track/index/metaindex as returned by 
// loadtrack

// overwrite needs to be a global variable because render is invoked repeatedly
// in the case of a bulk load. before the load the global variable is set to 
// whatever value is suitable for the first file, and it is then set to 0 for
// subsequent loads.

function render(response,filename,origin,p4,overwrite) 
{ var i,opts,segno,proactive,r,flag,ovr,disp,ind,s,loadflags=null ;
  var extn = filename.substring(filename.length-4).toLowerCase(),newseg ;
  if(overwrite) render.overwrite = overwrite ; 
  if(origin=='refresh') { ovr = 'refresh' ; origin = p4 ; }
  else { ovr = render.overwrite ; loadflags = p4 ; }
  // purge body of everything except mapdiv
  if(mapparent==null||body==null) 
    mapparent = body = document.getElementsByTagName("body")[0] ;

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

  // if filename is a relative url, convert it to absolute
  if((ovr=='refresh'||origin.substring(0,3)=='uri')&&!absuri(filename))
  { i = document.location.href.indexOf('?') ; 
    if(i<0) filename = reluri(document.location.href,filename) ; 
    else filename = reluri(document.location.href.substring(0,i),filename) ; 
  }

  newseg = loadtrack(response,filename,ovr,origin,prefs,loadflags) ;
  if(!newseg) return ; // load failed or refreshing a  component
  r = getbounds(newseg) ;
  ind = segments.length - 1 ; 

  /* ----------------------- process the photolist -------------------------- */

  // if we're displaying a track/index, check its photolist and load it if nec
  if(segments[0].level==0) 
  { if(routeprops.list&&segments[ind].list!=routeprops.list)
      abend(L.inconsistentlists+' ' + segments[ind].list + 
            ' vice ' + routeprops.list) ; 
    if(segments[ind].list!=routeprops.list) getlist(segments[ind].list,'tcx') ;
    routeprops.list = segments[ind].list ;
  }
  else if(newseg.level==1&&newseg.list&&extn=='.tcx') 
    getlist(newseg.list,'tcx',newseg) ;

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

  // update uri
  if( ovr=='load'&&(origin=='uriform'||origin=='urilink')           
   && (trackextns.indexOf(extn)>=0) )
  { i = thispage.indexOf('?') ;
    if(i<0) i = thispage.length ; 
    thispage = document.URL = thispage.substring(0,i) + '?track=' + filename ;
    history.pushState(null,null,thispage) ;
  }

  // extend bounds of new tracks to include old ones if kept 
  if(map&&(s=map.getBounds())) 
  { if(ovr=='add') r = r.union(s) ; render.bounds = null ; }
  else 
  { if(ovr=='add'&&render.bounds) r = r.union(render.bounds) ; 
    render.bounds = r ; 
  }

  flag = 0 ; 
  if(map==null) // all this only done on first call
  { for(i=body.childNodes.length-1;i>=0;i--)
      if(body.childNodes[i]!=mapdiv&&body.childNodes[i]!=telltale.div) 
        body.removeChild(body.childNodes[i]) ;
    disp = -1 ; 
    if(loadflags)
    { if(loadflags.indexOf('f')>=0) disp = 0 ; 
      else if(loadflags.indexOf('t')>=0) disp = 1 ; 
      else if(loadflags.indexOf('s')>=0) disp = 2 ; 
    }
    if(disp<0) { if(segments[0].level) disp = 0 ; else disp = 1 ; }
    if(disp==0) disp = google.maps.MapTypeId.ROADMAP ;
    else if(disp==1) disp = google.maps.MapTypeId.TERRAIN ;
    else disp = google.maps.MapTypeId.SATELLITE ;
    opts = { zoom: 22,
             center: r.getCenter(),
             scaleControl: true,
             rotateControl: false,
             streetViewControl: false,
             keyboardShortcuts: false, // this is needed to keep the Google Maps
                                       // API from hijacking the arrow keys
             mapTypeId: disp,
             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'}) ;
    google.maps.event.addListener(map,"click",selpoint) ;
    selpoint.clickscale = (r.getSouthWest().lat()+r.getNorthEast().lat()) / 2 ;
    selpoint.clickscale = 156543 * Math.cos(selpoint.clickscale*Math.PI/180) ;

    // set up buttons
    setbtn = genbutton('settings') ;
    if(segments[0].level==0)  
    { segbtn = genbutton('segment') ; 
      wpbtn = genbutton('waypoint') ; 
      scissorsbtn = genbutton('scissors') ;
      penbtn = genbutton('pen') ;
      photobtn = genbutton('camera') ;
    }
    undobtn = genbutton('undo') ;
    redobtn = genbutton('redo') ;
    dlbtn = genbutton('dl') ;
    acbtn = genbutton(prefs.email?1:0) ;
    if(segments[0].level==0) window.addEventListener('resize',resizer) ; 
    if(loadflags&&loadflags.indexOf('p')>=0) flag = 1 ; 
  }

  // google may repeatedly add a margin: see https://stackoverflow.com/...
  //   ...questions/8170023/google-maps-api-3-zooms-out-on-fitbounds/41753053
  if(ovr!='refresh') map.fitBounds(r,0) ; 

  donesomething() ; // ie loaded and optimised, but we can't record the fact
                    // until we've defined the buttons
  // draw the new points
  recurse(newseg,'draw',{map:map,sel:selpoint}) ; 
  if(segments.length>1) connect(segments.length-2) ; 

  if(segments[0].level==0) // draw cursor and altitude profile
  { if(ovr=='load') selected = [0,0] ; 
    if(pro&&pro.active) flag = 1 ; 
    if(pro&&pro.prodiv) pro.prodiv.parentNode.removeChild(pro.prodiv) ;
    if(pro&&pro.curdiv) pro.curdiv.parentNode.removeChild(pro.curdiv) ; 
    pro = new profiletype(map) ; 
    mapparent.appendChild(pro.prodiv) ; 
    mapparent.appendChild(pro.curdiv) ; 
    pro.active = flag ; 
    drawprofile() ;
    getalts(segments,1,drawprofile) ; 
    drawsel() ; 
  }
  else { blackout(dlbtn) ; selected = [-1,-1] ; }

  render.overwrite = "add" ; 
  if(loadflags&&loadflags.indexOf('z')>=0) eventually(function()
    { infowindow.open(cogwheelmenu(0),getbtnpos(0),'settings') ; }) ;
}
/* ------------------------------- genbutton -------------------------------- */

function genbutton(name)
{ var u,v,b,g,k,h,gtitle=null,ktitle,a ;
  u = domcreate('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' ;
  u.style.textAlign = 'center' ;

  if(name=='settings') 
  { h = routeinfo ; 
    u.index = 0 ; 
    if(segments[0].level>0) ktitle = L.controlmenu ; 
    else ktitle = L.routeprops ;
  }
  else if(name=='segment') 
  { h = seginfo ; u.index = 1 ; ktitle = L.segmentprops ; }
  else if(name=='waypoint') 
  { h = wpinfo ; u.index = 2 ; ktitle = L.waypointprops ; }
  else if(name=='scissors') 
  { h = snip ; 
    u.index = 3 ; 
    ktitle = caps(L.splitsegment) + ' ' + L.atwaypoint ; 
    gtitle = caps(L.splitsegment) + ' ' + L.nosplitsegment ; 
    gtitle = L.nosplitsegment ;
  }
  else if(name=='pen') 
  { h = labelprompt ; u.index = 4 ; ktitle = L.labelwaypoint ; }
  else if(name=='camera') 
  { h = photoprompt ; 
    u.index = 5 ; 
    ktitle = L.addaphoto ; 
    gtitle = L.noaddaphoto ;
  }
  else if(name=='undo') 
  { h = undo ; 
    u.index = 6 ; 
    ktitle = L.undolatest ; 
    gtitle = L.noundolatest ;
  }
  else if(name=='redo') 
  { h = redo ; 
    u.index = 7 ; 
    ktitle = L.redolatest ; 
    gtitle = L.noredolatest ;
  }
  else if(name=='dl') 
  { h = function() { dl(0) ; }  ; 
    u.index = 8 ; 
    ktitle = L.saveroute ; 
    gtitle = L.nosaveroute ;
  }
  else // account
  { function acfactory(opt) 
    { return function() 
      { infowindow.open(acmenu(opt),getbtnpos(9),'account') ; } ;
    }
    h = acfactory(name) ; 
    u.index = 9 ; 
    if(name) ktitle = L.account ; 
    else ktitle = L.register[1] + '/' + L.register[0] ; 
  }
  if(u.index!=9) u.style.marginRight = '4px' ;

  if(gtitle==null) gtitle = ktitle ; 
  v = btnicon(name) ; 
  g = v.grey ; 
  k = v.black ;

  if(name!='scissors'&&name!='undo'&&name!='redo')
  { b = k ; u.title = ktitle ; u.onclick = h ; a = 1 ; }
  else { b = g ; u.title = gtitle ; a = 0 ; }
  u.appendChild(b) ;

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

  return { ui:u , greyimg:g , blackimg:k , active:a ,
           greytitle:gtitle, blacktitle:ktitle , handler:h } ; 
}
/* -------------------------------------------------------------------------- */

function addload(ovr)
{ infowindow.close() ; 
  if(ovr!='load'&&ovr!='add') alert(inject2(L.illegalopt,ovr,L.load)) ;
  if(ovr=='load') 
  { var msg = unsavedmsg(1) ; if(msg!=null) if(!confirm(msg)) return ; }
  infowindow.open(filedialogue(ovr),getbtnpos(0),'addload') ; 
}
function refresh(segno)
{ var s,i,stem=null ; 
  function renfactory(t,s) 
  { return function(r) { render(r,t,'refresh',s) ; } ; } ;

  infowindow.close() ; 
  seg = segments[0].pts[segno] ;
  s = seg.tlink ; 
  i = s.indexOf('?') ; 
  if(i>=0) { stem = s.substring(0,i) ; s = s.substring(i+1) ; }
  i = s.indexOf('track=') ; 
  if(i>=0) s = s.substring(i+6) ; 
  i = s.indexOf('&') ; 
  if(i>=0) s = s.substring(0,i) ; 
  trackuri = reluri(stem,s) ; 
  s = s.substring(1+s.lastIndexOf('/')) ; 
  textprompt(inject(L.waitingfor,s),null,null,'loadwait') ; 
  readuri(renfactory(trackuri,segno)) ;
}
/* -------------------------------------------------------------------------- */
/*           WPINFO IS A MENU GIVING ACCESS TO THE SETALT FUNCTION            */
/* -------------------------------------------------------------------------- */

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

function rellist(list)
{ var i , x = document.URL , str ;
  if((i=x.lastIndexOf('?'))>=0) x = x.substring(0,i) ; 
  return reluri(x,list) ; 
}
// opt 1 => download segments as index
// opt 2 => download index as metaindex
// opt null => download track/index/metaindex as is
function dl(opt,e) 
{ var str,i,npix,s0,s1,name,extn,filename=null,legend,ooo,time,tlast,interp=[] ;
  var flag , lim , routename = abbreviate(routeprops.filename) ; 
  infowindow.close() ; 
  if(e) e.preventDefault() ; 

  if(!opt&&segments[0].level<1) // ie. it's a normal track
  { if(segments.length>1) { alert(L.youneedtocombine) ; return ; }
    interp = interpol() ; 
    if(interp.length>3) done(interp) ; 
    getalts(segments,1,drawprofile) ; 
  }

  // filename
  flag = filename = null ; 
  if(segments[0].level==2) str = L.metaindex ; 
  else if(segments[0].level==1) str = L.index ; 
  else str = L.gpstrack ;

  if(!opt)
  { if(segments[0].level>0) 
    { if(segments[0].filename) filename = abbreviate(segments[0].filename)[0] ; 
      else filename = ascify(segments[0].title) ;
    } 
    else if( routename[0] && !opt && routename[1]!='.fit'
          && trackextns.indexOf(routename[1])>=0 ) filename = routename[0] ;
    else if(routeprops.title) { filename = routeprops.title ; flag = 1 ; }
  }
  if(flag) 
  { filename = filename.split(' ') ; 
    if(opt) lim = 25 ; else lim = 15 ; 
    for(i=1;i<filename.length&&filename[0].length+filename[i].length<lim;i++)
      filename[0] += filename[i] ; 
    filename = ascify(filename[0]) ; 
  }
  name = window.prompt(inject(L.nameforX,str),filename?filename:"") ;
  if(name==null) return ; 
  else if(name) filename = name ; 
  else filename = L.untitled ; 

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

  // photo list
  if(npix>0&&imginfo.status=='ready')
  { if(imginfo.type=='tcx') routeprops.list = imginfo.uri ; // 'tcx' vice 'uri'
    else if(imginfo.type=='uri'||imginfo.type=='uriform') 
      routeprops.list = rellist(imginfo.uri) ; 
    else routeprops.list = '$FILE$/' + imginfo.uri ; 
  }
  
  // save an index as itself or a route as an index
  if(opt) { indexdl(filename+'.rte',name,opt,e) ; return ; }
  else if(segments[0].level>0) { indexdl(filename+'.rte',name) ; return ; }

  // if we get here it's a download of a normal track
  div = domcreate('div') ; 
  for(i=0;i<4;i++)
  { legend = L.saveas + ' ' + filename + trackextns[i] ;
    function cofactory(parm) { return function() { confirmeddl(parm) ; } ; } ;
    div.appendChild(genclickfn(cofactory(filename+trackextns[i]),legend,'br')) ;
  }

  if(interp.length>3)
    div.appendChild(genspan(L.gapsfixed,'br','font-style:italic')) ; 

  for(tlast=null,ooo=i=0;i<segments[0].pts.length;i++)
  { time = segments[0].pts[i].t ;
    if(tlast!=null&&time!=null&&time<tlast) ooo = 1 ; // out of order
    if(time!=null) tlast = time ;
  }
  if(ooo) div.appendChild(genspan(L.yourtimes,'br')) ;

  div.appendChild(doclink('outputformats',L.formatdoc)) ; 

  infowindow.open(div,getbtnpos(opt?0:8),'download') ; 
}
function canceldl() { infowindow.close() ; }

function indexdl(filename,name,opt,e)
{ infowindow.close() ; 
  var i , r , str , optparms=[] ; // shift-click bypasses optimisation
  if(!opt) str = writeindex(segments[0],0) ;
  else
  { r = new routetype() ; 
    r.level = segments[0].level + 1 ; 
    r.title = name ; 
    r.gallery = routeprops.gallery ; 
    r.pts = new Array(segments.length) ; 
    if(segments[0].level) optparms = [metaparms] ; 
    else if(!e||!e.shiftKey) optparms = [indparms] ; 
    for(i=0;i<segments.length;i++) r.pts[i] = indexify(segments[i],optparms) ;                      
    str = writeindex(r,0) ; 
  }
  if(str) saveAs(new Blob([str],{type:"text/plain;charset=utf-8"}),filename) ;
}

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

  infowindow.close() ; 
  div = domcreate('div') ; 
  div.appendChild(genspan(inject(L.waitingfor,L.missingalts),'br')) ;
  div.appendChild(genspan(L.dontwanttowait+' ')) ;
  div.appendChild(genclickfn(canceldl,L.cancel)) ;
  div.appendChild(genspan('; ')) ;
  div.appendChild(genclickfn(semiconfirmeddl,L.useinterp)) ;
  div.appendChild(genspan('; ')) ;

  div.appendChild(genclickfn(reconfirmeddl,L.savemissing)) ;
  div.appendChild(genspan('.')) ;
  infowindow.open(div,getbtnpos(8),'download') ; 
  getalts(segments,1,drawprofile,reconfirmeddl) ;
}
function semiconfirmeddl() // turn off callback to reconfirmeddl
{ getalts(null,null,null,null) ; reconfirmeddl() ; }

function reconfirmeddl()
{ var filename = pendingdl ; 
  if(filename==null) return ;
  var i,str,mode,len=filename.length ;//filename is null
  infowindow.close() ; 
  mode = filename.substring(len-3) ;
  // record optimisation 
  routeprops.pts = segments[0].pts ;
  routeprops.optim = segments[0].optim ; 

  if((str=writegps(routeprops,segments[0].pts,mode,prefs.precision))!=null) 
  { unsavedchanges = [] ; 
    if(mode=="fit") mode = "application/octet-stream" ;
    else mode = "text/plain;charset=utf-8" ;
    saveAs(new Blob([str],{type:mode}),filename) ;
  }
  pendingdl = null ; 
}
/* -------------------------------------------------------------------------- */
/*                             OPTIMISATION                                   */
/* -------------------------------------------------------------------------- */

function optimaccept(result,seg,parms,segno)
{ seg.optim = { already:0 ,             origlen:seg.pts.length , 
                len:result.ind.length , parms:parms } ;
  actions[nactions++] = 
    [ 'optimise' , segno , parms , seg.pts , seg.title , seg.optim , result ] ; 
}
function optimparms(v,m)
{ var wp = Math.pow(10,200/v) , t=2*Math.pow(wp,0.33) ; 
  return {tol:t,maxsep:m,wppenalty:wp,vweight:1} ; 
}
function pen2detail(pen) { return Math.floor(0.5+200/Math.log10(pen)) ; }

function optimmerge(seg,res)
{ var s,i,k,n=res.ind.length,s=new Array(n) ;
  if(res.h) for(i=0;i<n;i++) 
  { s[i] = seg.pts[res.ind[i]] ; 
    k = s[i].h ; s[i].h = res.h[i] ; res.h[i] = k ;
  } // after optimisation res.h contains the altitudes before smoothing
  else for(i=0;i<n;i++) s[i] = seg.pts[res.ind[i]] ; 
  deltify(s) ; 
  seg.pts = s ;
}
/* -------------------------------------------------------------------------- */

function optimwork(segno)
{ infowindow.close() ;

  var d , slider = document.createElement('input') , seg = segments[segno];
  var div = document.createElement('div') ,i , r=null , hold , box ; 
  var shadow=null , inp , ptsdiv , dd , newseg=null , v , maxsep , sold ; 

  function dgen(d,v,n)
  { while(d.childNodes.length>0) 
      d.removeChild(d.childNodes[d.childNodes.length-1]) ;
    d.appendChild(domcreate('span',v)) ; 
    if(!n) n = '----' ;
    ptsdiv = domcreate('div',n+' '+L.points,'style','float:right') ; 
    d.appendChild(ptsdiv) ; 
  }

  greyout(setbtn) ; 
  bulkout(0) ; 
  dragging = 2 ; 
  recurse(seg,'obliterate') ; 
  if(segno>0) disconnect(segno-1) ; 
  disconnect(segno) ; 
  shadow = new google.maps.Polyline(new linepath(seg,-1,0,'darkgrey',3,-1)) ;
  shadow.setMap(map) ;

  div.setAttribute('style','position:absolute;left:0;bottom:0;width:160px;'+
                           'z-index:999;background:white') ; 

  if(prefs&&prefs.detail) v = prefs.detail ; 
  else v = pen2detail(defparms.wppenalty) ; 
  if(v<1) v = 1 ; else if(v>100) v = 100 ; 
  if(prefs&&!prefs.maxsep) maxsep = null ; else maxsep = 100 ;  

  function posit(s) { deltify(s) ; seg.pts = s ; drawprofile() ; draw(seg) ; }

  function vender(val,maxval)
  { var parms,line,s,n,i ;
    seg.line.setMap(null) ; 
    for(i=0;i<sold.length;i++) sold[i].h = hold[i] ; 
    r = optimise(sold,optimparms(val,maxval)) ;
    n = r.ind.length ; 
    s = new Array(n) ; 
    for(i=0;i<n;i++) { s[i] = sold[r.ind[i]] ; s[i].h = r.h[i] ; }
    posit(s) ; 
    dgen(dd,val,n) ; 
  }

  // store incoming values
  sold = seg.pts ; 
  hold = new Array(sold.length) ;
  for(i=0;i<sold.length;i++) hold[i] = sold[i].h ; 

  // create slider
  d = document.createElement('div') ;
  d.setAttribute('style','padding-bottom:4px;text-align:center') ; 
  slider.setAttribute('type','range') ; 
  slider.setAttribute('min',1) ;
  slider.setAttribute('max',100) ;
  slider.setAttribute('value',v) ;
  slider.setAttribute('id',"optimrange") ;
  slider.setAttribute('style','width:140px') ; 
  slider.oninput = function() { dgen(dd,this.value) ; }
  slider.onmouseup = function() { v = this.value ; vender(v,maxsep) ; }
  d.appendChild(slider) ; 
  div.appendChild(d) ; 

  // text response
  dd = document.createElement('div') ;
  dd.setAttribute('style','margin:0;padding:0 10px 4px 10px;font-size:80%') ; 
  dgen(dd,v,seg.pts.length) ; 
  div.appendChild(dd) ; 
  vender(v,maxsep) ; 

  // limit point separation?
  d = document.createElement('div') ;
  d.setAttribute('style',
                 'padding:0 0 4px 10px;font-size:80%;white-space:nowrap') ; 
  s = domcreate('label',L.limsep,'for','limsep') ; 
  d.appendChild(s) ; 

  box = domcreate('input',null,'type','checkbox') ; 
  box.setAttribute('id','limsep') ; 
  if(maxsep) box.setAttribute('checked',true) ; 
  box.onchange = function() 
  { if(box.checked) maxsep = 100 ; else maxsep = 0 ; vender(v,maxsep) ; }
  d.appendChild(box) ; 
  div.appendChild(d) ; 

  d = document.createElement('div') ;
  d.setAttribute('style','padding-bottom:4px;text-align:center') ; 

  // remove the overlaid lines and reset buttons
  function unshadow()
  { seg.line.setMap(null) ; 
    shadow.setMap(null) ; 
    body.removeChild(div) ; 
    blackout(setbtn) ; 
    bulkout(1) ; 
    dragging = 0 ; 
  }

  // cancel button 
  inp = domcreate('button',caps(L.cancel)) ; 
  inp.style.cursor = "pointer" ; 
  inp.onclick = function() 
  { var i ; 
    unshadow() ; 
    for(i=0;i<sold.length;i++) sold[i].h = hold[i] ; 
    posit(sold) ; 
  }  
  d.appendChild(inp) ; 

  // submit button
  inp = domcreate('button',L.ok,'type','submit') ; 
  inp.style.cursor = "pointer" ; 
  function accept(response)
  { var i,s,n = r.ind.length,k ; 
    seg.pts = sold ; 
    for(i=0;i<sold.length;i++) seg.pts[i].h = hold[i] ; 
    optimaccept(r,seg,optimparms(v,maxsep),segno) ; 
    for(s=new Array(n),i=0;i<n;i++) 
    { s[i] = sold[r.ind[i]] ; 
      k = s[i].h ; s[i].h = r.h[i] ; r.h[i] = k ;
    } // now r.h contains the altitudes before smoothing
    posit(s) ; 
    donesomething() ; 
    selected[1] = 0 ; 
    seginfo() ; 
  }
  inp.onclick = function() 
  { unshadow() ; 
    if(!prefs.email||(v==prefs.detail&&(!!maxsep)==!!prefs.maxsep)) accept(0) ; 
    else textprompt(L.detail,[v,maxsep],accept,'optim') ;
  }
  d.appendChild(inp) ; 

  div.appendChild(d) ; 
  body.appendChild(div) ; 
}  
/* -------------------------------------------------------------------------- */

function redelta(pts,s1)
{ if(s1>0) pts[s1-1].delta = dist(pts[s1-1].pos,pts[s1].pos) ; 
  if(s1<pts.length-1) pts[s1].delta = dist(pts[s1].pos,pts[s1+1].pos) ; 
  else pts[s1].delta = null ; 
}
/* ------------------------------- retitle ---------------------------------- */

function retitle(opt,segno) 
{ var newval , oldval , useval , msg , s0 , s1 , i , ind , retpage , item ; 
  var filename = (segno&&segments[segno].origin)
                   ?segments[segno].origin[0]:null ;
  if(segno!=null&&segno!=undefined) oldval = segments[segno][opt] ;
  else 
  { if(segments[0].level>0) oldval = segments[0][opt] ; 
    else oldval = routeprops[opt] ; 
    segno = null ; 
  }
  infowindow.close() ; 

  if(oldval==null) 
  { if(opt=='title') msg = L.addtitle ;
    else if(opt=='desc') msg = L.adddesc ;
  }
  else
  { if(opt=='title') msg = caps(L.edittitle) ;
    else if(opt=='desc') msg = caps(L.editdesc) ;
  }
  msg += ':' ;
  useval = oldval ; 

  if(opt=='desc') 
  { textprompt(msg,oldval==null?'':oldval,respond,'desc') ; return ; }
  else respond(window.prompt(msg,useval==null?'':useval)) ;

  function respond(newval)
  { var list = [] ;
    if(newval==null||newval==oldval) return ; 
    if(newval=='') newval = null ; 
    // I used to nullify segment titles which were equal to the old route title,
    // but this led to titleless routes if I saved as index
    if(oldval&&newval&&segno==null&&opt=='desc')
    { for(i=0;i<segments.length;i++) 
        if(segments[i].desc==oldval) list.push(i) ; 
      for(i=0;i<list.length;i++) segments[list[i]].desc = null ; 
    }
    if(opt=='desc') 
    { if(segno==null) routeprops.desc = newval ; 
      else segments[segno].desc = newval ; 
    }
    else if(segno==null) setdomtitle(routeprops.title=newval) ; 
    else segments[segno].title = newval ; 

    actions[nactions++] = [ 'edit'+opt , oldval , newval , segno ,list ] ; 
    if(segno!=null&&opt!='desc'&&segments[segno].origin) 
      segments[segno].origin[0] = null ; 

    donesomething() ;
    if(segno==null||segments[0].level) routeinfo() ; 
    // doesn't work for desc for unknown reasons. it seems that the InfoWindow
    // open() has no effect, but there are no diagnostics to explain why.
    // logically it ought to return a status code.
    else seginfo() ;
  }
}
/* ------------------------------- restars ---------------------------------- */

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

function routeinfo()
{ infowindow.close() ;
  infowindow.open(cogwheelmenu(dragging),getbtnpos(0),'settings') ; 
}
/* ------------------------------- calwork --------------------------------- */

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

function manualcal()
{ infowindow.close() ; 
  var x,y,s0=selected[0] ;
  x = prompt(L.enteroffset) ;
  if(x==null) return ; 
  y = parseFloat(x) ; 
  if(isNaN(y)) { alert(inject(L.isnan,x)) ; return ; }
  calwork(s0,y) ; 
  done(['recal',s0,y]) ; 
}  
/* ---------------------------- googlecalwork ------------------------------ */

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

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

function googlereg() 
{ var s0 = selected[0] ; 
  infowindow.close() ; 
  getalts([segments[s0]],-1,drawprofile,s0) ;
}
function googleadd() 
{ var s0 = selected[0] ; 
  infowindow.close() ; 
  getalts([segments[s0]],-2,drawprofile,s0) ;
}
/* --------------------------------- help ----------------------------------- */

function help() 
{ infowindow.close() ; 
  infowindow.open(rmhelpdiv(segments[0].level),getbtnpos(0),'help') ; 
}
/* --------------------------------- wpdel ---------------------------------- */

function wpdelwork(s0,s1)
{ var i,d=segments[s0].pts,response=d[s1],clen=d.length ;
  response.setmap(null,null) ;
  for(i=s1;i<clen-1;i++) d[i] = d[i+1] ;
  d.length = clen-1 ; 
  if(s1>0) redelta(d,s1-1) ; else redelta(d,s1) ;
  selected = [s0,s1] ; 
  if(s1==d.length) selected[1] -= 1 ;  
  redrawconnect(s0,s1) ; 
  drawsel() ; 
  drawprofile() ; 
  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,d=segments[s0].pts,j,x,len=d.length,lim ;
  if(s0>0) disconnect(segments[s0-1]) ; 
  disconnect(segments[s0]) ; 

  for(lim=Math.floor(len/2),i=0;i<lim;i++)
  { j = (len-1)-i ; x = d[i] ; d[i] = d[j] ; d[j] = x ; }
  for(i=0;i<len-1;i++) d[i].delta = d[i+1].delta ; 
  d[len-1].delta = null ; 
  
  for(i=0;i<d.length;i++) 
    if(d[i].label=='Right') d[i].changelabel('Left') ;
    else if(d[i].label=='Left') d[i].changelabel('Right') ;

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

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

function dupsegwork(s0)
{ var i,newcol=snipcolour(segments[s0].hue),n=segments.length ; 
  segments.length += 1 ; 

  segments[n] = new routetype() ;
  segments[n].pts = new Array(segments[s0].pts.length) ; 
  for(i=0;i<segments[s0].pts.length;i++) 
    segments[n].pts[i] = segments[s0].pts[i].clone() ; 
  segments[n] = segments[s0].clone() ;
  segments[n].dots = segments[n].dothandler = null ; 
  segments[n].hue = segments[s0].hue ;
  segments[n].colour = newcol ;
  draw(segments[n]) ;
  connect(n-1) ; 
  if(selected[0]==s0) selected[0] = n ; 
  pro.active = 1 ; 
  drawpro(pro,segments,pro.sel) ; 
  drawsel() ; 
}
/* -------------------------------------------------------------------------- */

function dupseg()
{ var s0 = selected[0] ; 
  infowindow.close() ; 
  dupsegwork(s0) ; 
  done(['dupseg',s0]) ; 
}
/* -------------------------------------------------------------------------- */
/*    DRAGGING A (POSSIBLY NEWLY INSERTED) WAYPOINT IS QUITE A LOT OF WORK    */
/* -------------------------------------------------------------------------- */

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

function inswp(dir)
{ var s0=selected[0],s1=selected[1],bounds,del,pos,pts=segments[s0].pts ;
  var len = pts.length ;
  if(len==1) pos = pts[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(pts[2].pos,pts[1].pos,1.5) ; 
  else if(s1<len) pos = interp(pts[s1-1].pos,pts[s1+1].pos,0.5) ;
  else pos = interp(pts[s1-2].pos,pts[s1-1].pos,1.5) ;
  pts[s1].setpos(pos) ; 
  redelta(pts,s1) ; 
  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].pts.length ;
  startpos = segments[s0].pts[s1].pos ;
  inserted = insparm?1:0 ; 
  infowindow.close() ; 
  bulkout(0) ; 

  segments[s0].line.setMap(null) ;  // for some reason the effect is delayed

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

  colour = segments[s0].colour ;
  if(segments[s0].clickhandler!=null) 
  { google.maps.event.removeListener(segments[s0].clickhandler) ;
    segments[s0].clickhandler = null ; 
  }
  map.panToBounds(new google.maps.LatLngBounds(startpos,startpos)) ;

  seg0 = seg2 = null; 
  if(s1>1)
  { seg0 = new google.maps.Polyline(new linepath(segments[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(segments[s0],start,end,colour)) ;
  seg1.setMap(map) ;

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

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

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

function undraggit()
{ var s0=selected[0],s1=selected[1],i,s1dash,d=segments[s0].pts,pos=d[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].line = 
    new google.maps.Polyline(new linepath(segments[s0],-1,0,colour)) ;
  segments[s0].line.setMap(map) ;
  d[s1].h = null ;
  getalts(segments,100,drawprofile) ; 
  redelta(d,s1) ; 

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

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

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

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

function interpol()
{ var s0,s1,pts,n,opos,npos,i,lambda ;
  var task = [ 'extra' , selected[0] , selected[1] ] ;
  for(s0=0;s0<segments.length;s0++) 
    for(pts=segments[s0].pts,s1=1;s1<pts.length;s1++)
      if(pts[s1-1].delta>100) 
  { opos = pts[s1-1].pos ;
    npos = pts[s1].pos ;
    n = Math.floor(pts[s1-1].delta/95) ;
    insert(s0,s1,n) ; 
    for(i=0;i<n;i++) 
    { lambda = (i+1) / (n+1) ; 
      pts[s1+i].setpos(new google.maps.
                          LatLng(lambda*npos.lat()+(1-lambda)*opos.lat(),
                                 lambda*npos.lng()+(1-lambda)*opos.lng())) ;
    }
    for(i=0;i<=n;i++) redelta(pts,s1+i) ; 
    if(selected[0]==s0&&s1<=selected[1]) selected[1] += n ; 
    task.push([s0,s1,pts.slice(s1-1,s1+n+1)]) ; 
    s1 += n ;
  }
  return task ; 
}
function extrapts(opt)
{ infowindow.close() ; 
  var task = interpol() ; 
  getalts(segments,1,drawprofile) ; 
  done(task) ; 
  if(opt==1) dl(0) ; else routeinfo() ; 
}
/* ------------------------------- combine1 --------------------------------- */

function combine1(sa,sb)
{ var n=segments[sa].pts.length ;
  undraw(segments[sb]) ; 
  if(sb>0) disconnect(segments[sb-1]) ; 
  if(selected[0]==sb) selected = [ sa , selected[1]+n ] ; 
  segments[sa].pts = segments[sa].pts.concat(segments[sb].pts) ; 
  redelta(segments[sa].pts,n) ;
}
function combinework(base,n)
{ var s0,i ;
  disconnect(segments[base+n-1]) ; 
  for(s0=base+1;s0<base+n;s0++) combine1(base,s0) ;
  for(s0=base+n;s0<segments.length;s0++) segments[s0-(n-1)] = segments[s0] ; 
  segments.length -= n-1 ; 
  if(selected[0]>base) for(i=base;i<selected[0];i++)
  { selected[0] -= 1 ; selected[1] += segments[i].pts.length ; }
  redraw(base) ; 
  connect(base) ; 
  drawprofile() ; 
  drawsel() ; 
}
/* -------------------------------------------------------------------------- */

function combiner(base,n)
{ var i , task = [ 'combine' , base , n ] ;
  infowindow.close() ; 
  for(i=base;i<n;i++) task.push(segments[i].pts.length,segments[i],
                                segments[i].hue,segments[i].colour) ; 
  combinework(base,n) ; 
  done(task) ; 
}  
function combine() { combiner(0,segments.length) ; }
function combinef() { combiner(selected[0],2) ; }
function combineb() { combiner(selected[0]-1,2) ; }

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

function uncombine(task)
{ var i,s0,llen,nlen,base=task[1],n=task[2] ; 

  segments.length += n-1 ;
  for(s0=segments.length-1;s0-(n-1)>base;s0--) 
    segments[s0] = segments[s0-(n-1)] ; 
  disconnect(segments[base]) ; 
  for(s0=n-1,i=task.length-4;i>=5;i-=4,s0--)
  { llen = segments[base].pts.length - task[i] ;
    segments[base+s0] = new routetype() ; 
    segments[base+s0].pts = segments[base].pts.slice(llen) ;
    segments[base+s0].pts[segments[base+s0].pts.length-1].delta = null ; 
    segments[base+s0].hue = task[i+2] ;
    segments[base+s0].colour = task[i+3] ;
    draw(segments[base+s0]) ; 
    connect(base+s0) ; 
    segments[base].pts.length = llen ; 
  }
  segments[base].pts.length = task[3] ; // invalid array length
  segments[base].pts[task[3]-1].delta = null ; 
  connect(base) ; // Uncaught TypeError: segments[(i + 1)] is undefined at
                  // routemaster.js line 412
  redraw(base) ; 

  if(selected[0]>base) selected[0] += n-1 ; 
  while(selected[1]>=(n=segments[selected[0]].pts.length))
  { selected[1] -= n ; selected[0] += 1 ; }

  drawprofile() ; 
  drawsel() ; 
}
/* -------------------------------- setalt ---------------------------------- */

function setalt(edit,precision)
{ infowindow.close() ; 
  var s0=selected[0],s1=selected[1],x,y=null,oldalt=null ; 
  if(edit) 
  { oldalt = segments[s0].pts[s1].h.toFixed(precision) ;
    x = prompt(L.enteralt,oldalt) ;
  }
  else x = prompt(L.enteralt) ;
  if(x==null) return ; 
  if(x!=''&&isNaN(y=parseFloat(x))) { alert(inject(L.isnan,x)) ; return ; }
  if(y==null&&oldalt==null) return ; 
  if(y!=null&&Math.abs(y-oldalt)<Math.pow(0.1,precision)/2) return ; 
  done(['setalt',s0,s1,segments[s0].pts[s1].h,y]) ; 
  segments[s0].pts[s1].h = y ; 
  drawprofile() ; 
  wpinfo() ; 
}  
function delalts(s0)
{ var s1,task=[] ;
  for(s1=0;s1<segments[s0].pts.length;s1++) if(segments[s0].pts[s1].h!=null)
  { task.push([s1,segments[s0].pts[s1].h]) ; segments[s0].pts[s1].h = null ; }
  infowindow.close() ;  
  done(['delalts',s0,task]) ;
  drawprofile() ; 
}  
/* -------------------------------------------------------------------------- */
/*   THE LABELS ARE ACCESSED FROM THE PEN BUTTON OR BY CLICKING ON THE MAP    */
/* -------------------------------------------------------------------------- */

function labelprompt()
{ var i,oldcaption='',oldlabel,label='Generic',s0=selected[0],s1=selected[1] ;
  var str , flag = (infowindow.close()=='wpinfo') ; 
  var pt = segments[s0].pts[s1] ;

  function editlabel(caption,label) 
  { if(caption==null) label = null ; else if(!caption) caption = null ; 
    if(label==null) 
    { if(oldlabel) // how can this condition fail?
      { done(['editlabel',s0,s1,oldcaption,null,icons.names[oldlabel],null]) ; 
        pt.setlabel() ;
      }
      if(flag) wpinfo() ; else walkto(s0,s1) ; 
      return ; 
    } 
    label = icons.names[label] ;
    oldlabel = icons.names[oldlabel] ;
    if(caption==oldcaption&&label==oldlabel) 
    { if(flag) wpinfo() ; else walkto(s0,s1) ; return ; } 

    pt.setlabel(label,caption) ; 
    pt.setlabelmap(map,selpoint) ; 
    done(['editlabel',s0,s1,oldcaption,caption,oldlabel,label]) ; 
    if(flag) wpinfo() ; else walkto(s0,s1) ; 
  }
  
  oldlabel = pt.label ; 
  if(oldlabel) 
    for(oldcaption=pt.marker.title,i=icons.names.length-1;i>=0;i--)
      if(i==0||icons.names[i]==oldlabel) { oldlabel = i ; break ; }
  if(oldcaption==null) oldcaption = '' ;
  if(oldlabel==null) str = L.enterlabel ; else str = L.modlabel ; 
  textprompt(str,oldcaption,editlabel,'label',oldlabel) ;
}
function unlabel()
{ var s0,s1,lab=[],pt ;
  for(s0=0;s0<segments.length;s0++) for(s1=0;s1<segments[s0].pts.length;s1++)
  { pt = segments[s0].pts[s1] ;
    if(pt.label)
    { lab.push([ s0 , s1 , pt.label , pt.caption ]) ; 
      pt.setlabel(null,null) ; 
    }
  }
  if(lab.length) done(['unlabel',lab]) ;
  routeinfo() ;
} 
/* -------------------------------------------------------------------------- */

function photoprompt(e) 
{ var s0=selected[0],s1=selected[1],pt,photo ;
  if(e) e.preventDefault() ;
  var flag = (infowindow.close()=='wpinfo') ; 

  if(imginfo.status!='ready') 
  { infowindow.open(filedialogue("list"),getbtnpos(5),'getlist') ; return ; }
  else
  { pt = segments[s0].pts[s1] ;
    photo = window.prompt(L.enterphoto,'') ;

    if(photo!=null&&photo!='') 
    { done(['editphoto',s0,s1,pt.photo.length,null,photo]) ; 
      pt.addphoto([photo]) ; 
      pt.setphotomap(map,selpoint) ;
      walkto(s0,s1) ;
      return ; 
    }
  }
  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 pt = segments[s0].pts[s1] ;
  var photo = window.prompt(L.newphoto,pt.photo[ind]) ;

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

var lmove,rmove ; 

function phadvance(s0,s1,ind)
{ var i ; 
  for(ind++;;ind++)
  { if(ind>=segments[s0].pts[s1].photo.length) { ind = 0 ; s1 += 1 ; }
    if(s1==segments[s0].pts.length) 
    { s0 += 1 ; if(s0==segments.length) return null ; else s1 = 0 ; }
    if(ind<segments[s0].pts[s1].photo.length) 
      if((i=findimg(segments[s0].pts[s1].photo[ind]))) return [s0,s1,ind,i] ;
  }
}
function phretreat(s0,s1,ind)
{ var i ; 
  for(ind--;;ind--)
  { if(ind<0) { s1 -= 1 ; ind = -1 ; }
    if(s1<0)
    { if(s0==0) return null ; else s0 -= 1 ; 
      s1 = segments[s0].pts.length-1 ; 
    }
    if(ind<0) ind = segments[s0].pts[s1].photo.length - 1 ;
    if(ind>=0&&(i=findimg(segments[s0].pts[s1].photo[ind]))) 
      return [s0,s1,ind,i] ;
  }
}
function next() { dodisplay(rmove[0],rmove[1],rmove[2],1) ; }
function prev() { dodisplay(lmove[0],lmove[1],lmove[2],-1) ; }

function backtogps() 
{ document.onkeydown = keystroke ; 
  window.removeEventListener('resize',function(){genpic('resize');}) ; 
  window.removeEventListener('touchstart',startswipe,false) ;        
  window.removeEventListener('touchmove',midswipe,false) ;        
  window.removeEventListener('touchend',endswipe,false) ;
  genmenu('del') ; 
  genpic() ; 
  mapparent.removeChild(imgdiv) ; 
  imgdiv = null ; 
  walkto(selected[0],selected[1],0) ;
}
function display(ind)
{ document.onkeydown = imgwalk ;
  window.addEventListener('resize',function(){genpic('resize');}) ; 
  window.addEventListener('touchstart',startswipe,false) ;        
  window.addEventListener('touchmove',midswipe,false) ;        
  window.addEventListener('touchend',endswipe,false) ;
  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 item,litem=null,ritem=null,sect ;
  var infowords = { exit: L.exitfs , enter: L.enterfs ,
                    notes: L.notes , origin: caps(L.gpstrack) } ; 
  genpic('uncaption') ; 
  selected[0] = s0 ; 
  selected[1] = s1 ; 
  lmove = phretreat(s0,s1,ind) ;
  if(lmove) litem = imginfo.sect[lmove[3][0]].list[lmove[3][1]] ;
  rmove = phadvance(s0,s1,ind) ;
  if(rmove) ritem = imginfo.sect[rmove[3][0]].list[rmove[3][1]] ;

  item = findimg(segments[s0].pts[s1].photo[ind]) ; 
  sect = imginfo.sect[item[0]] ;
  item = sect.list[item[1]] ; 
  genpic(imgdiv,item,sect.title,imginfo.sizes,
         lmove?prev:null,litem,backtogps,rmove?next:null,ritem,
         infowords,pixhelpdiv(),dir,0) ; 
}
/* ------------------------------ image walk -------------------------------- */

function imgwalk(e)
{ e.preventDefault() ; 
  if(e.keyCode==70) enterfullscreen() ; else simulate(e.keyCode) ;
}
function simulate(btn)
{ if(btn==39) { if(rmove) next() ; return ; }
  else if(btn==37) { if(lmove) prev() ; return ; }
  else if(btn==32) genpic('spacebar') ; 
  else if(btn==38) genpic('enlarge') ; 
  else if(btn==40) genpic('reduce') ; 
  else if(btn==77) genmenu('toggle') ; 
  else backtogps() ; 
}
/* --------------------------------- photo info ----------------------------- */

function phinfo(i) // pixinfodiv is in pixlib
{ infowindow.close() ; 
  var s0 = selected[0] , s1 = selected[1] , pos = segments[s0].pts[s1].pos ; 
  var item = imginfo.sect[i[0]].list[i[1]] , sname = imginfo.sect[i[0]].name ;
  infowindow.open(pixinfodiv(item,sname,imginfo.sizes),pos,'phinfo') ; 
}
/* ------------------------- snip: apply scissors  -------------------------- */

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

  segments[s0+1] = segments[s0].clone() ;
  segments[s0+1].pts = segments[s0].pts.slice(s1) ;
  segments[s0+1].dots = segments[s0].dots ;
  segments[s0+1].dothandler = segments[s0].dothandler ;
  segments[s0].dots = segments[s0].dothandler = null ; 
  segments[s0+1].hue = segments[s0].hue ;
  segments[s0+1].colour = newcol ;
  segments[s0].pts.length = s1 ; 
  segments[s0].pts[s1-1].delta = null ; 
  draw(segments[s0]) ;
  connect(s0) ; 
  draw(segments[s0+1]) ; 
  selected = [s0+1,0] ; 
  drawprofile() ;
  drawsel() ; 
}
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)
{ var d0,d1=segments[s0].pts,d2,n ;
  undraw(segments[s0]) ; 
  if(s1==0&&s0>0)
  { d0 = segments[s0-1].pts ;
    n = d0.length ;
    undraw(segments[s0-1]) ; 
    if(s0>0) disconnect(segments[s0-1]) ; 
    d0.push(d1[0]) ; 
    segments[s0].pts = d1.slice(1,d1.length) ; 
    redelta(d0,n) ; 
    draw(segments[s0-1]) ; 
    connect(s0-1) ; 
    if(selected[0]==s0&&selected[1]==s1)
    { selected[0] -= 1 ; selected[1] = n ; }
    else if(selected[0]==s0) selected[1] -= 1 ; 
  }
  else if(s1==d1.length-1&&s0<segments.length-1)
  { d2 = segments[s0+1].pts ;
    undraw(segments[s0+1]) ; 
    disconnect(segments[s0]) ; 
    d2.unshift(d1[d1.length-1]) ; 
    redelta(d2,0) ; 
    d1.length -= 1 ; 
    d1[d1.length-1].delta = null ;

    draw(segments[s0+1]) ; 
    connect(s0) ; 
    if(selected[0]==s0&&selected[1]==d1.length)
    { selected[0] += 1 ; selected[1] = 0 ; }
    else if(selected[0]==s0) selected[1] += 1 ; 
  }
  draw(segments[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(segno)
{ var i,r,seg ; 
  if(segments[0].level==0) 
  { seg = segments ; 
    if(segno>0) disconnect(seg[segno-1]) ; 
    disconnect(seg[segno]) ; 
  }
  else seg = segments[0].pts ; 

  r = seg[segno] ;
  recurse(r,'obliterate') ; 
  for(i=segno;i<seg.length-1;i++) seg[i] = seg[i+1] ; 
  seg.length -= 1 ; 

  if(segments[0].level==0)
  { if(segno>0) connect(segno-1) ;
    selected[1] = 0 ; 
    if(selected[0]>=segments.length) 
    { selected[0] = segments.length-1 ; 
      selected[1] = segments[selected[0]].pts.length - 1 ; 
    }
    drawprofile() ; 
    drawsel() ; 
  }
  return r ; 
}
function discard()
{ var segno = selected[0] ; // selected[0] is reset by infowindow.close()
  if(segno>=0) { infowindow.close() ; done(['bin',segno,binwork(segno)]) ; }
}
function undiscard(segno,replacement)
{ var i,seg ; 
  if(segments[0].level==0) 
  { seg = segments ; if(segno>0) disconnect(seg[segno-1]) ; }
  else seg = segments[0].pts ; 

  seg.length += 1 ; 
  for(i=seg.length-1;i>segno;i--) seg[i] = seg[i-1] ; 
  seg[segno] = replacement ; 
  recurse(seg[segno],'draw') ;

  if(segments[0].level==0) 
  { connect(segno-1) ; 
    connect(segno) ; 
    if(selected[0]>=segno) selected[0] += 1 ; 
    drawprofile() ; 
    drawsel() ; 
  }
}
/* ---------------------- swapseg: swap two segments  ----------------------- */

function swapsegwork(s0)
{ var i , temp = segments[s0+1] ; 
  segments[s0+1] = segments[s0] ;
  segments[s0] = temp ; 
  for(i=s0>0?s0-1:0;i<s0+2&&i<segments.length;i++) reconnect(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,opt)
{ if(x=='interpolate'||x=='optimise'||(x=='load'&&opt==0)||(x=='add'&&opt==0)) 
    return 0 ; 
  else return 1 ; 
}
// ['editlabel',s0,s1,oldcaption,caption,oldlabel,label]
function done(something) 
{ if( nactions>0 && unsavedchanges.length>0 && something[0]=='editlabel'
   && actions[nactions-1][0]=='editlabel'
   && actions[nactions-1][1]==something[1] 
   && actions[nactions-1][2]==something[2] 
   && actions[nactions-1][5] && something[6] ) 
                                       // don't merge change with create/delete
  { actions[nactions-1][4] = something[4] ; // caption
    actions[nactions-1][6] = something[6] ; // label
  }
  else { actions[nactions++] = something ; donesomething() ; }
}
function donesomething()
{ actions.length = nactions ; 
  if(nactions>1) blackout(undobtn) ; 
  greyout(redobtn) ; 
  if(actiontype(actions[nactions-1][0],actions[nactions-1][1])!=0) 
  { if(unsavedchanges.length>=3) unsavedchanges.push(null) ; 
    else unsavedchanges.push(actionname(actions[nactions-1])) ;
  }
}
// [ 'optimise' , s0+i , defparms , pts , props.title , props.optim , res ]
function optimswap(action,doing)
{ var s0 = action[1] , i , s , r = action[6] , k , pt , seg = segments[s0] ;
  var n = r.ind.length ; 

  if(doing=='redo') 
  { seg.optim = action[5] ; 
    s = new Array(n) ; 
    for(i=0;i<n;i++) 
    { s[i] = seg.pts[r.ind[i]] ; k = r.h[i] ; r.h[i] = s[i].h ; s[i].h = k ; }
    seg.pts = s ; 
  }
  else 
  { seg.optim = null ; 
    seg.pts = action[3] ; 
    for(i=0;i<n;i++) 
    { pt = seg.pts[r.ind[i]] ; k = r.h[i] ; r.h[i] = pt.h ; pt.h = k ; }
  }

  deltify(seg.pts) ; 
  redraw(s0) ;
  drawprofile() ; 
  if(s0==selected[0]) { selected[1] = 0 ; drawsel(seg.pts,0) ; }
  seginfo() ; 
}
// [ 'refresh' , segno , route ] ;
function refreshswap(action)
{ var segno = action[1] , r = segments[0].pts[segno] ;
  recurse(r,'obliterate') ; 
  segments[0].pts[segno] = action[2] ; 
  action[2] = r ; 
  recurse(segments[0].pts[segno],'draw') ; 
}
/* --------------------------------- undo  ---------------------------------- */

function undo()
{ infowindow.close() ;  
  var opts = actionname(actions[nactions-1]) ;
  if(actions[nactions-1][0]=='editdesc'&&actions[nactions-1][3]!=null) 
    opts = inject(L.editxdesc,segments[actions[nactions-1][3]].title) ; 
  opts = inject(L.undo,opts) ;
  infowindow.open(genclickfn(confirmedundo,opts),getbtnpos(6),'undo') ; 
}
function confirmedundo()
{ var i,ano=nactions-1,action=actions[ano][0],s0=actions[ano][1],s1,caption ;
  var oldcaption,task,ind,d,pt,s2 ; 
  infowindow.close() ;  

  if( action!='revseg' && action!='dupseg' && action!='interpolate'
   && action!='swapseg' && action!='unlabel' ) 
    s1 = actions[ano][2] ;
  s2 = actions[ano][3] ;

  if(action=='bin') undiscard(s0,s1) ; 
  // [ 'load', s0 , n , loadprops.title , null ]
  else if(action=='load'||action=='add') 
  { if(segments[0].level==0) actions[ano][3] = segments[s0] ; 
    else actions[ano][3] = segments[0].pts[s0] ;
    binwork(s0) ; 
  }
  else if(action=='refresh') refreshswap(actions[ano]) ; 
  else if(action=='snip') // undo snip
  { selected = [ s0 , segments[s0].pts.length-1 ] ; 
    undraw(segments[s0]) ; 
    combine1(s0,s0+1) ; 
    for(i=s0+1;i<segments.length-1;i++) segments[i] = segments[i+1] ; 
    segments.length -= 1 ; 
    drawprofile() ;
    draw(segments[s0]) ;
    drawsel() ; 
  } 
  else if(action=='xferwp') // undo transfer waypoint
  { if(s1==0) xferwpwork(s0-1,segments[s0-1].pts.length-1) ; 
    else xferwpwork(s0+1,0) ;
  }
  else if(action=='editlabel')  // undo create/edit/delete label
  { segments[s0].pts[s1].setlabel(actions[ano][5],s2) ;
    segments[s0].pts[s1].setlabelmap(map,selpoint) ;
  }
  else if(action=='unlabel') for(i=0;i<s0.length;i++)
  { task = s0[i] ; 
    pt = segments[task[0]].pts[task[1]] ;
    pt.setlabel(task[2],task[3]) ; 
    pt.setlabelmap(map,selpoint) ; 
  }
  else if(action=='edittitle') 
  { if(s2==null)
    { setdomtitle(routeprops.title=s0) ; 
      d = actions[ano][4] ;
      for(i=0;i<d.length;i++) segments[d[i]].title = s0 ; 
    }
    else segments[s2].title = s0 ; 
  }
  else if(action=='editdesc') 
  { if(s2==null) 
    { routeprops.desc = s0 ; 
      d = actions[ano][4] ;
      for(i=0;i<d.length;i++) segments[d[i]].desc = s0 ; 
    }
    else segments[s2].desc = s0 ; 
  }
  else if(action=='wpdel')      // ['wpdel',s0,s1,wpdelwork(s0,s1)]
  { insert(s0,s1,1) ; 
    d = segments[s0].pts ;
    d[s1] = s2 ;
    d[s1].setmap(map,selpoint) ;
    redelta(d,s1) ; 
    redrawconnect(s0,s1) ;
    drawsel(segments[s0].pts,s1) ; 
    selected = [s0,s1] ;
  }
  else if(action=='move')
  { if(actions[ano][5]) wpdelwork(s0,s1) ; else move(s0,s1,s2) ; }
  else if(action=='recal') calwork(s0,-s1) ; 
  else if(action=='googlecal') for(i=0;i<segments[s0].pts.length;i++)
    segments[s0].pts[i].h = s1[i] ;
  else if(action=='googlereg'||action=='googleadd') 
    getalts([segments[s0]],-3,drawprofile,s0,-s1,-s2) ;
  else if(action=='setalt') segments[s0].pts[s1].h = s2 ;
  else if(action=='combine') uncombine(actions[ano]) ; 
  else if(action=='revseg') revsegwork(s0) ; 
  else if(action=='dupseg') 
  { if(selected[0]==segments.length-1) selected[0] = s0 ; 
    recurse(segments[segments.length-1],'obliterate') ; 
    disconnect(segments.length-2) ; 
    disconnect(segments.length-1) ; 
    segments.length -= 1 ; 
    drawprofile() ;
  }
  else if(action=='swapseg') swapsegwork(s0) ; 
  else if(action=='stars') routeprops.stars = s0 ; 
  else if(action=='deltimes') for(i=0;i<s1.length;i++) 
    segments[s0].pts[s1[i][0]].t = s1[i][1] ;
  else if(action=='delalts') 
  { for(i=0;i<s1.length;i++) segments[s0].pts[s1[i][0]].h = s1[i][1] ;
    drawprofile() ; 
  }
  else if(action=='optimise') optimswap(actions[ano],'undo') ; 
  else if(action=='editphoto') 
  { ind = s2 ;
    if(actions[ano][5]==null)      // undo delete
      for(i=segments[s0].pts[s1].photo.length;i>ind;i--)
        segments[s0].pts[s1].photo[i] = segments[s0].pts[s1].photo[i-1] ;
    if(ind>=segments[s0].pts[s1].photo.length)
    { segments[s0].pts[s1].addphoto([actions[ano][4]]) ;
      segments[s0].pts[s1].setphotomap(map,selpoint) ;
    }

    else segments[s0].pts[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] ;
    d = segments[task[0]].pts ;
    d.splice(task[1],task[2].length-2) ; 
    redelta(d,task[1])  ;
  }

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

function move(s0,s1,pos)
{ segments[s0].pts[s1].setpos(pos) ; 
  redelta(segments[s0].pts,s1) ; 
  redrawconnect(s0,s1) ; 
  drawsel() ; 
}
/* --------------------------------- redo  ---------------------------------- */

function redo()
{ infowindow.close() ;  
  var opts = actionname(actions[nactions]) ;
  if(actions[nactions][0]=='editdesc'&&actions[nactions][3]!=null) 
    opts = inject(L.editxdesc,segments[actions[nactions][3]].title) ; 
  opts = inject(L.redo,opts) ;
  infowindow.open(genclickfn(confirmedredo,opts),getbtnpos(7),'redo') ; 
}
function confirmedredo()
{ var i,action=actions[nactions][0],s0=actions[nactions][1],s1,caption,a,b,c ;
  var task,ind,photo,pt,s2 ; 
  if( action!='revseg' && action!='dupseg' && action!='interpolate'
   && action!='swapseg' && action!='unlabel' ) 
    s1 = actions[nactions][2] ;
  s2 = actions[nactions][3] ;
  infowindow.close() ; 

  if(action=='bin') binwork(s0) ; 
  else if(action=='load'||action=='add') 
  { undiscard(s0,actions[nactions][3]) ; actions[nactions][3] = null ; }
  else if(action=='refresh') refreshswap(actions[nactions]) ; 
  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].pts[s1].setlabel(actions[nactions][6],actions[nactions][4]) ;
    segments[s0].pts[s1].setlabelmap(map,selpoint) ; 
  }
  else if(action=='unlabel') for(i=0;i<s0.length;i++)
  { task = s0[i] ; 
    pt = segments[task[0]].pts[task[1]] ;
    pt.setlabel(null,null) ; 
    pt.setlabelmap(map,selpoint) ; 
  }
  else if(action=='edittitle') 
  { if(s2==null) 
    { setdomtitle(routeprops.title=s1) ; 
      d = actions[nactions][4] ;
      for(i=0;i<d.length;i++) segments[d[i]].title = null ; 
    }
    else segments[s2].title = s1 ; 
  }
  else if(action=='editdesc') 
  { if(s2==null) 
    { routeprops.desc = s1 ; 
      d = actions[nactions][4] ;
      for(i=0;i<d.length;i++) segments[d[i]].desc = null ; 
    }
    else segments[s2].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]) ; 
    if(actions[nactions][5]) selected = [ s0 , s1 ] ; 
  }
  else if(action=='recal') calwork(s0,s1) ; 
  else if(action=='googlecal') googlecalwork(s0) ; 
  else if(action=='googlereg'||action=='googleadd') 
    getalts([segments[s0]],-3,drawprofile,s0,s1,s2) ;
  else if(action=='setalt') segments[s0].pts[s1].h = actions[nactions][4] ;
  else if(action=='combine') combinework(0,segments.length) ; 
  else if(action=='revseg') revsegwork(s0) ; 
  else if(action=='dupseg') dupsegwork(s0) ; 
  else if(action=='swapseg') swapsegwork(s0) ; 
  else if(action=='stars') routeprops.stars = s1 ; 
  else if(action=='deltimes') for(i=0;i<s1.length;i++) 
    segments[s0].pts[s1[i][0]].t = null ;
  else if(action=='delalts') 
  { for(i=0;i<s1.length;i++) segments[s0].pts[s1[i][0]].h = null ;
    drawprofile() ; 
  }
  else if(action=='optimise') optimswap(actions[nactions],'redo') ; 
  else if(action=='editphoto') 
  { photo = actions[nactions][5] ;
    if(actions[nactions][4]==null) 
    { segments[s0].pts[s1].addphoto([photo]) ;
      segments[s0].pts[s1].setphotomap(map,selpoint) ;
    }
    else segments[s0].pts[s1].setphoto(s2,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]].pts.slice(0,task[1]) ;
    b = task[2].slice(1,task[2].length-1) ; 
    c = segments[task[0]].pts.slice(task[1]) ;
    segments[task[0]].pts = a.concat(b,c) ; 
    redelta(segments[task[0]].pts,task[1]) ; 
    redelta(segments[task[0]].pts,task[1]+task[2].length-1) ; 
  }

  nactions += 1 ; 
  if(nactions==actions.length) greyout(redobtn) ; 
  blackout(undobtn) ; 
  if(actiontype(actions[nactions-1][0])!=0) unsavedchanges.push(action) ;
  if(action=='dltimes'||action=='stars') routeinfo() ; 
  else if(action=='optimise') seginfo() ; 
  else if( action=='editphoto' || action=='editlabel'
       || (action=='move'&&actions[nactions-1][5]) ) walkto(s0,s1) ;
  else if(action=='editdesc')
  { if(actions[nactions-1][3]==null) routeinfo() ; 
    else highlight(0,actions[nactions-1][3]) ; 
  }
}
/* -------------------------------- actionname ------------------------------ */

function actionname(x)
{ var i,s ; 
  if(x[0]=='bin') 
  { if(segments[0].level==0) return L.deletesegment ; 
    else if(segments[0].level==1) return L.deleteroute ; 
    else return L.deleteindex ; 
  }
  if(x[0]=='snip') return L.splitsegment ; 
  if(x[0]=='xferwp') return L.xferwaypoint ; 
  if(x[0]=='editlabel') // ['editlabel',s0,s1,oldcaption,caption,oldlabel,label]
  { if(!x[6]) return L.dellabel ;
    else if(!x[5]) return L.labelpt ;
    else return L.editlabel ; 
  }
  if(x[0]=='unlabel') return L.removelabels ;
  if(x[0]=='edittitle'||x[0]=='editindex title') return L.edittitle ; 
  if(x[0]=='editdesc') return L.editdesc ; 
  if(x[0]=='editinfo') return L.editinfo ; 
  if(x[0]=='wpdel') return L.wpdel ; 
  if(x[0]=='move') { if(x[5]) return L.wpins ; else return L.wpdrag ; }
  if(x[0]=='recal') return L.recalalts ; 
  if(x[0]=='googlecal') return L.googlelats ; 
  if(x[0]=='googlereg') return L.regressalts ; 
  if(x[0]=='googleadd') return L.calibalts ; 
  if(x[0]=='setalt') return L.wpalt ; 
  if(x[0]=='resign') return L.wpicon ; 
  if(x[0]=='combine') return inject(L.combinesegments,x[2]) ;
  if(x[0]=='revseg') return L.revsegment ; 
  if(x[0]=='dupseg') return L.dupsegment ; 
  if(x[0]=='optimise') 
  { if(x[4]) return inject(L.optimsth,x[4]) ; else return L.optim ; }
  if(x[0]=='refresh') return L.refresh ; 
  if(x[0]=='deltimes') return L.deltimes ; 
  if(x[0]=='delalts') return L.delalts ; 
  if(x[0]=='editphoto') 
  { if(x[5]==null) return L.delphoto ; 
    else if(x[4]==null) return L.addphoto ;
    else return L.modphoto ; 
  }
  if(x[0]=='extra') return L.interpextra ; 
  if(x[0]=='stars')
  { if(x[2]==null) 
    { for(s='',i=0;i<x[1];i++) s += '\u2605' ; return inject(L.clear,s) ; }
    else { for(s='',i=0;i<x[2];i++) s += '\u2605' ; return inject(L.set,s) ; }
  }
  if(x[0]=='swapseg') return L.swapseg ; 
  if(x[0]=='load') 
  { if(x[2]) return inject(L.loadsth,x[2]) ; else return L.load ; }
  if(x[0]=='add') return inject(L.addsth,x[2]?x[2]:L.loadnewseg)
  abend(inject(L.unrecogaction,x[0])) ;
}
/* -------------------------------------------------------------------------- */

function optimise(ipts,parms)
{ var clen = ipts.length ; 
  var stk,nnstk,stk2,i,j,k,m,pi=Math.PI,tol=parms.tol,e,h,buf,n,k0,q ; 
  var opos,oalt,npos,nalt,npt,mpos,malt,arccentre,arctol,pathpos,jump ; 
  var s,sx,sy,sxx,sxy,sxxx,sxxy,sxxxx,u ; 
  var legal,theta,omega,x,y,hyp,tdist,maxtheta,mintheta,d,dh,od,odh,odash ; 
  var bearings=new Array(clen),nstk=new Array(clen),backptr=new Array(clen) ; 

  stk = [ { err:0 , pathpos:1 , prev:-1 } ] ;

  // 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 pts, 
  // 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
  // ipts[k-1].
  while(stk[0].pathpos<clen)
  { pathpos = stk[0].pathpos ;
    backptr[pathpos-1] = stk[0].prev ;
    opos = ipts[pathpos-1].pos ;
    oalt = ipts[pathpos-1].h ; 
    // try extending to pathpos+i
    for(arctol=null,jump=nnstk=i=0;i<clen-pathpos;i++)
    { npt = ipts[pathpos+i] ; 
      npos = npt.pos ; 
      nalt = npt.h ; 
      jump += ipts[pathpos+i-1].delta ;
      if( i>0 && parms.maxjump && jump>parms.maxjump ) break ; 
      if(i==0) hyp = ipts[pathpos-1].delta ; 
      else 
      { hyp = dist(opos,npos) ; 
        if(parms.maxsep&&hyp>parms.maxsep*0.99999) break ; 
      }
      omega = angle(opos,npos) ; 
      // find the min and max legal bearing
      if(tol&&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 ;
        }
      } 
      /* -------------------------------------------------------------------- */

      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 = ipts[pathpos+m].pos ;
        malt = ipts[pathpos+m].h ; 
        x = bearings[m].hyp ; 
        theta = bearings[m].omega ; 
        d = x * Math.sin(theta-omega) ; 
        dh = 0 ;
        if(tol&&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(tol&&d*d+dh*dh>tol*tol) { legal = 0 ; break ; } 
        tdist += ipts[pathpos-1+m].delta * 
                                   ( 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(npt.label||npt.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]=clen-1,j=m-2,i=stk[0].prev;i>=0;i=backptr[i],j--) nstk[j] = i ;
  if(!parms.vweight) return {ind:nstk,h:null} ; 

  stk = backptr = stk2 = null ; 

  // make smoothed altitude estimates
  for(k=i=0;i<m;i++)
  { if(i) jump = 1 + nstk[i] - nstk[i-1] ; else jump = 1 ; 
    if(i<m-1) jump += nstk[i+1] - nstk[i] ; 
    if(jump>k) k = jump ; 
  }
  h = new Array(m) ; 
  buf = new Array(k) ;

  for(i=0;i<m;i++)
  { h[i] = ipts[nstk[i]].h ; 
    if(ipts[nstk[i]].h==null||ipts[nstk[i]].h==undefined) continue ; 

    // collect points
    k = 0 ; 
    if(i) for(ind=nstk[i-1],n=nstk[i]-ind,j=1;j<n;j++) 
    { y = ipts[ind+j].h ;
      if(y!=null&&y!=undefined) 
        buf[k++] = { h:y , x:ipts[ind+j].delta , wt:0 } ; 
    }
    ind = nstk[i] ; 
    k0 = k ; 
    buf[k++] = { h:ipts[ind].h , x:ipts[ind].delta , wt:0 } ;
    if(i<m-1) for(ind++,n=nstk[i+1]-ind,j=0;j<n-1;j++)
    { y = ipts[ind+j].h ;
      if(y!=null&&y!=undefined) 
        buf[k++] = { h:y , x:ipts[ind+j].delta , wt:0 } ; 
    }
    if(k<3) continue ; 

    // compute weighted moments
    for(y=0,j=0;j<k;j++) { x = buf[j].x ; buf[j].x = y ; y += x ; }
    for(y=buf[k0].x,q=j=0;j<k;j++) 
    { x = buf[j].x -= y ; buf[j].wt = Math.exp(-x*x/200) ; }
    for(sxxxx=sxxy=sxxx=sxy=sxx=sy=sx=s=j=0;j<k;j++) 
    { x = buf[j].x ;
      y = buf[j].h ; 
      q = buf[j].wt ;
      sxxxx += q * x * x * x * x ; 
      sxxy  += q * x * x * y ; 
      sxxx  += q * x * x * x ;  
      sxy   += q * x * y ; 
      sxx   += q * x * x ; 
      sy    += q * y ; 
      sx    += q * x ; 
      s     += q ; 
    }
    if(s<1.5||sxx<50) continue ;
    else if(sxx<200||k<5) q = (sy*sxx-sx*sxy) / (s*sxx-sx*sx) ; 
    else
    { x = sx*sxxx  - 2*sxx*sxx ;
      y = sx*sxxxx - 2*sxx*sxxx ;
      q = - ( y*(sxy*sxx-sy*sxxx) - x*(sxxy*sxx-sy*sxxxx) ) ;
      q /=  ( y*(s*sxxx -sx*sxx)  - x*(s*sxxxx -sxx*sxx ) ) ; 
    } 
    h[i] = q ; 
  }
  return {ind:nstk,h:h} ; 
}
/* -------------------------------------------------------------------------- */

function decimate(pts)
{ var i,j,k,r = new Array(pts.length) ; 
  for(r[0]=pts[0],k=1,i=0;i<pts.length-1;i=j)
  { for(j=i+1;j<pts.length-1;j++)
      if(pts[j].label||pts[j].photo.length||dist(pts[i].pos,pts[j].pos)>2) 
        break ; 
    r[k++] = pts[j] ; 
  } 
  r.length = k ; 
  return r ;  
}
// distance of a point from a segment
function distptseg(d01,d02,d12) // p0 is the point, p1-p2 the segment
{ if(d01<=0||d02<=0) return 0 ; else if(d12<=0) return d01 ;
  var u = (d01*d01+d12*d12-d02*d02) / (2*d01*d12) ; // u = cos(theta)
  if(u<=0) return d01 ; else if(d01*u>=d12) return d02 ; 
  else if(Math.abs(u)>=1) return 0 ; 
  else return d01 * Math.sqrt(1-u*u) ;
}
// distance between two segments
function distsegseg(d0,d00,d01,d1,d10,d11)
{ if(d0<=0) return distptseg(d00,d01,d1) ;
  else if(d1<=0) return distptseg(d00,d10,d0) ;
  var A,B, C = ( d10*d10 + d01*d01 - d00*d00 - d11*d11 ) / (2*d0*d1) ;
  if(Math.abs(C)>=1) return d00 ; // segments are parallel
  A = (d00*d00-d10*d10)/d0 ; 
  B = (d00*d00-d01*d01)/d1 ; 
  if( Math.abs(A+(B+d1+d0*C)*C)<=d0*(1-C*C)
   && Math.abs(B+(A+d0+d1*C)*C)<=d1*(1-C*C) ) return 0 ; // segments cross
  return Math.min( distptseg(d00,d01,d1) , distptseg(d10,d11,d1) , 
                   distptseg(d00,d10,d0) , distptseg(d01,d11,d0) ) ;
}
/* -------------------------------------------------------------------------- */

// convert a colour (array of 3 numbers) to hex
function hexify(x)
{ function hexdigit(y)
  { y = Math.floor(y+0.5) ; return ('00'+y.toString(16)).substr(-2) ; }
  return '#' + hexdigit(x[0]) + hexdigit(x[1]) + hexdigit(x[2]) ; 
}
function dehexify(x)
{ var i,y=[0,0,0] ;
  function numerate(x) { return '0123456789abcdef'.search(x.toLowerCase()) ; }
  if(x.charAt(0)!='#') { alert(inject(L.logicerr,'dehexify')) ; return y ; }
  for(i=0;i<3;i++)
    y[i] = numerate(x.charAt(2*i+2)) + 16*numerate(x.charAt(2*i+1)) ;
  return y ;
}
function d3(x,y)
{ return (x[0]-y[0])*(x[0]-y[0]) + (x[1]-y[1])*(x[1]-y[1]) + 
         (x[2]-y[2])*(x[2]-y[2]) ; 
} 
// downsample a segment by a factor of 10
function dsseg(pts)
{ var j , k , points , n = pts.length , npts = 1 + Math.floor(n/10) ; 
  if(npts<=3) 
  { if(n==0) abend(L.emptyseg) ; 
    points = [ 0 , 0 , 0 ] ;
    for(j=0;j<3;j++) 
      points[j] = { pos:pts[Math.floor(j*(n-1)/2)].pos , dist:null } ;
    for(j=0;j<2;j++) points[j].dist = dist(points[j].pos,points[j+1].pos) ;
    return points ;
  }
  points = new Array(npts) ; 
  for(j=0;j<npts;j++)
  { k = Math.floor(0.5+(n-1)*j/(npts-1)) ;
    points[j] = { pos: pts[k].pos , dist:null } ;
    if(j) points[j-1].dist = dist(points[j-1].pos,points[j].pos) ;
  }
  return points ; 
}
// compute proximity of two downsampled segments
function segprox(segi,segj)
{ var qnum,qden,i,j,d00,d01,d10,d11,d0,d1 ;
  for(qnum=qden=0,i=0;i<segi.length-1;i++) 
  { d00 = dist(segi[i].pos,segj[0].pos) ; 
    for(j=0;j<segj.length-1;d00=d01,j++) 
    { d01 = dist(segi[i].pos,segj[j+1].pos) ; 
      d10 = dist(segi[i+1].pos,segj[j].pos) ; 
      d11 = dist(segi[i+1].pos,segj[j+1].pos) ; 
      d0 = segi[i].dist ;
      d1 = segj[j].dist ;
      qnum += d0 * d1 / ( 1 + Math.sqrt(distsegseg(d0,d00,d01,d1,d10,d11)) ) ; 
      qden += d0 * d1 ; 
    }
  }
  return [qnum,qden] ;
}
/* -------------------------------------------------------------------------- */

// twiddle choice of colours from c to maximise separation of n points

function assigncolours(c,prox) 
{ var boosted,maxboost,iter,q,boost,maxj,i,j,k ;
  var nset = c.length , n = prox.length ; 
  if(n==1) return [ c[0] ] ;
  var cind = new Array(nset) , rind = new Array(n) ;
  function cdif(i,j) { return Math.sqrt(d3(c[i],c[j])) ; } 

  // set up initial assignment and twiddle it 
  for(i=0;i<nset;i++) cind[i] = null ; 
  for(i=0;i<n;i++) 
  { rind[i] = Math.floor(0.5+i*(nset-1)/(n-1)) ; cind[rind[i]] = i ; }

  for(boosted=2,iter=0;boosted>1&&iter<10;iter++) for(boosted=i=0;i<n;i++)
  { for(maxboost=0,j=i+1;j<n;j++)
    { for(boost=k=0;k<n;k++) if(k!=i&&k!=j)
      { q = cdif(rind[k],rind[i]) - cdif(rind[k],rind[j]) ;
        boost += (prox[j][k]-prox[i][k]) * q  ;
      }
      if(boost>maxboost) { maxboost = boost ; maxj = [ j , 0 ] ; }
    }
    for(j=0;j<nset;j++) if(cind[j]==null)
    { for(boost=k=0;k<n;k++) if(k!=i)
        boost += prox[i][k]*(cdif(rind[k],j)-cdif(rind[k],rind[i])) ;
      if(boost>maxboost) { maxboost = boost ; maxj = [ j , 1 ] ; }
    }
    if(maxboost>0)
    { if(maxboost>boosted) boosted = maxboost ; 
      j = maxj[0] ;
      k = rind[i] ; 
      if(maxj[1]>0) { rind[i] = j ; cind[k] = null ; }
      else { rind[i] = rind[j] ; rind[j] = k ; cind[k] = j ; }
      cind[rind[i]] = i ; 
    }
  }
  for(i=0;i<n;i++) rind[i] = c[rind[i]] ; 
  rind.length = n ; 
  return rind ; 
}
/* -------------------------------------------------------------------------- */

function listhues(n)
{ var g,denom,c,nc,lev,i,j,k,hue,nhue,prox,maxprox,maxi,zonked,nzonked ;
  var proxsum,q,huelen,h , A={x:0.65,y:0} , B={x:3/8,y:3/8} , C={x:0,y:1} ;
  if(n==1) return [ [ 255,0,0] ] ; // (g,b) is below/left of A, B and C

  c = new Array(n) ; 
  c[0] = [1,0,0] ; 
  c[1] = [0,0,1] ;

  for(nc=lev=2;nc<n;lev*=2) // increasingly fine divisions of the (g,b) triangle
  { huelen = ( (lev/2) * (lev/2-1) ) / 2 ;
    hue = new Array(huelen) ; 
    // bound hues away from the segment from [0,1,0] to [0,0.5,0.5]
    for(nhue=0,i=1;i<lev;i+=2) for(j=1;i+j<=lev;j+=2)
    { if(i>=j && (A.y-B.y)*i + (B.x-A.x)*j < (A.y*B.x-B.y*A.x)*lev ) continue ;
      if(i<j  && (B.y-C.y)*i + (C.x-B.x)*j < (B.y*C.x-C.y*B.x)*lev ) continue ;
      h = [ (lev-i-j)/lev , i/lev , j/lev ] ;
      q = 0.3*h[0] + 0.59*h[1] + 0.11*h[2] ; // luminance
      q = Math.min(2,0.65/q,1/Math.max(h[0],h[1],h[2])) ; 
      hue[nhue++] = [ q*h[0] , q*h[1] , q*h[2] ] ;
    }
    if(nhue+nc<=n) for(i=0;i<nhue;i++) c[nc++] = hue[i] ; 
    else // repeatedly zonk a colour which is maximally prox to other colours
    { prox = new Array(nhue) ; 
      for(i=0;i<nhue;i++) prox[i] = new Array(nhue) ; 
      zonked = new Array(nhue) ; 
      proxsum = new Array(nhue) ; 
      for(i=0;i<nhue;i++) for(proxsum[i]=j=0;j<nc;j++) 
        proxsum[i] += 1 / d3(hue[i],c[j]) ; 
      for(i=0;i<nhue-1;i++) for(j=i+1;j<nhue;j++) 
      { prox[i][j] = q = 1 / d3(hue[i],hue[j]) ; 
        proxsum[i] += q ; 
        proxsum[j] += q ; 
      }
      for(nzonked=0;nhue+nc>n+nzonked;nzonked++) 
      { for(maxprox=i=0;i<nhue;i++) if(!zonked[i]&&proxsum[i]>maxprox)
        { maxprox = proxsum[i] ; maxi = i ; }
        zonked[maxi] = 1 ; 
        for(i=0;i<maxi;i++) if(!zonked[i]) proxsum[i] -= prox[i][maxi] ;
        for(i=maxi+1;i<nhue;i++) if(!zonked[i]) proxsum[i] -= prox[maxi][i] ;
      }
      for(i=0;i<nhue;i++) if(!zonked[i]) c[nc++] = hue[i] ; 
    }
  }
  for(i=0;i<nc;i++) for(j=0;j<3;j++) c[i][j] *= 255 ; 
  return c ;
}
// extendhues generates n hues fairly distant from those supplied in hues
function extendhues(n,hues)
{ var nhues=hues.length,c=listhues(n+nhues),i,j,mindist,minc,dist,h ;
  for(i=0;i<nhues;i++)
  { for(minc=null,j=0;j<c.length;j++) if(c[j])
    { dist = d3(c[j],hues[i]) ;
      if(minc==null||dist<mindist) { mindist = dist ; minc = j ; }
    }
    c[minc] = null ; 
  }
  h = new Array(n) ; 
  for(i=j=0;j<c.length&&i<n;j++) if(c[j]) h[i++] = c[j] ;
  return h ;
}
/* -------------------------------------------------------------------------- */

function genshades(index)
{ var i , n = index.pts.length , shade = new Array(n) , sind ;
  for(i=0;i<n;i++) shade[i] = shadeofhue(index.hue,i,n) ;
  index.shades = [ hexify(shade[0]) , hexify(shade[n-1]) ] ; // gradient
  sind = assigncolours(shade,getprox(index)) ;
  for(i=0;i<n;i++) recurse(index.pts[i],'colour',sind[i]) ; 
}
/* -------------------------------------------------------------------------- */

function snipcolour(hue)
{ var alpha,i,j,k,n=segments.length,newcol,dist ;
  var maxdist,maxcol,shade,ssq,oldcols=new Array(n) ; 
  for(i=0;i<n;i++) oldcols[i] = dehexify(segments[i].colour) ; 

  for(i=0;i<=n;i++)
  { shade = shadeofhue(hue,i,n+1) ; 
    for(j=0;j<n;j++)
    { ssq = d3(shade,oldcols[j]) ;
      if(j==0||ssq<dist) { dist = ssq ; newcol = shade ; }
    }
    if(i==0||dist>maxdist) { maxdist = dist ; maxcol = newcol ; }
  }
  return hexify(maxcol) ;
}
function shadeofhue(hue,i,n)
{ if(n==1) return hue ; 
  var q,j,shade=[0,0,0],alpha=Math.min(2-hue[1]/128,255*(n+0.5)/640) ;
  q = (i+alpha)*640/(n+0.5) ; 
  if(q<=255) for(j=0;j<3;j++) shade[j] = hue[j] * q/255 ; 
  else for(q=(q-255)/510,j=0;j<3;j++) shade[j] = q*255 + (1-q)*hue[j] ;
  return shade ;
}
/* -------------------------------------------------------------------------- */

function segbounds(bounds,route)
{ var i,lat,lon ;
  // find maxima and minima
  for(i=0;i<route.pts.length;i++) 
  { lat = route.pts[i].pos.lat() ; 
    lon = route.pts[i].pos.lng() ; 
    if(bounds.minlon==null) 
    { bounds.minlon = bounds.maxlon = lon ; 
      bounds.minlat = bounds.maxlat = lat ;
    } 
    else 
    { if(lon<bounds.minlon) bounds.minlon = lon ; 
      else if(lon>bounds.maxlon) bounds.maxlon = lon ; 
      if(lat<bounds.minlat) bounds.minlat = lat ; 
      else if(lat>bounds.maxlat) bounds.maxlat = lat ; 
    }
  }
}
function deltify(pts) 
{ var i,len=pts.length ; 
  for(pts[len-1].delta=null,i=0;i<len-1;i++)
    pts[i].delta = dist(pts[i].pos,pts[i+1].pos) ;
}
function recurse(seg,opt,parms) 
{ var i , len = seg.pts.length , col = (opt=='colour'?hexify(parms):null) ; 
  if(seg.level==0) 
  { if(opt=='arrows') genarrows(seg) ;
    else if(opt=='north') for(i=0;i<len;i++) 
    { if(!parms.pos||seg.pts[i].pos.lat()>parms.pos.lat())
        parms.pos = seg.pts[i].pos ; 
    }
    else if(opt=='draw4') draw(seg,4,1) ; 
    else if(opt=='draw') 
    { draw(seg) ; 
      if(parms) for(i=0;i<len;i++) seg.pts[i].setmap(parms.map,parms.sel) ; 
    }
    else if(opt=='undraw') undraw(seg) ; 
    else if(opt=='deltas') deltify(seg.pts) ; 
    else if(opt=='getbounds') segbounds(parms,seg) ; 
    else if(opt=='listsegs') parms.push(dsseg(seg.pts)) ; 
    else if(opt=='colour') { seg.hue = parms ; seg.colour = col ; }
    else if(opt=='obliterate') 
    { for(i=0;i<len;i++) seg.pts[i].setmap(null,null) ; undraw(seg) ; }
    else alert(opt+' is an unrecognised option') ; 
  }
  else for(i=0;i<len;i++) recurse(seg.pts[i],opt,parms) ; 
}
/* -------------------------------------------------------------------------- */

function getprox(index)
{ var n=index.pts.length,dspts=new Array(n),i,j,k,i0,j0 ;
  var prox=new Array(n) ; 
  for(i=0;i<n;i++) 
  { dspts[i] = [] ; 
    recurse(index.pts[i],'listsegs',dspts[i]) ;
    prox[i] = new Array(n) ;
  }
  for(i0=0;i0<n-1;i0++) for(j0=i0+1;j0<n;j0++)
  { kk = [0,0] ; // numerator/denominator
    for(i=0;i<dspts[i0].length;i++) for(j=0;j<dspts[j0].length;j++)   
    { k = segprox(dspts[i0][i],dspts[j0][j]) ;
      kk[0] += k[0] ; 
      kk[1] += k[1] ; 
    }
    prox[j0][i0] = prox[i0][j0] = (kk[0]/kk[1]) * Math.pow(kk[1],0.1) ;
  }
  return prox ;
}
/* -------------------------------------------------------------------------- */

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 ;
}
function xmlify(s) 
{ var i,str='',c ; 
  for(i=0;i<s.length;i++) 
  { c = s.charAt(i) ; 
    if(c=='<') str += '&lt;' ;
    else if(c=='>') str += '&gt;' ;
    else if(c=='&') str += '&amp;' ;
    else if(c=="'") str += '&apos;' ;
    else if(c=='"') str += '&quot;' ;
    else str += c ; 
  }
  return str ; 
}
/* -------------------------------------------------------------------------- */
/*          FUNCTIONS FOR COMPUTING & DISPLAYING THE ALTITUDE PROFILE         */
/* -------------------------------------------------------------------------- */

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

  // if I were to dispense with the call to flatten I could reduce the work
  var p = flatten(segments) ;

  n = p.length ; 
  for(j=0;j<n&&p[j].h==null;j++) ; 
  if(j==n) for(i=0;i<n;i++) p[i].h = 0 ; 
  this.d = D = p[p.length-1].d ; 
  eps = D / nx ; 

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

  // a simple loop to find hmin, hmax
  for(this.hmax=this.hmin=null,i=0;i<nx;i++) if(this.h[i])
  { if(this.hmax==null||this.h[i]>this.hmax) this.hmax = this.h[i] ;
    if(this.hmin==null||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<p.length-1&&p[i].d<htarg;i++) ;
    this.pro2wp[hind] = p[i].sel ;
    if(i==0) continue ;
    d1 = p[i].d ;
    d0 = p[i-1].d ;
    if(d1>d0) lam = (htarg-d0) / (d1-d0) ; else lam = 1 ; 
    if(lam<0.5) this.pro2wp[hind] = p[i-1].sel ;
  }

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

var elevator=null,altthresh=null ; 

// action is drawprofile, ie. update profile whenever results are obtained
// getalts.action is occasionally reconfirmdl, ie. go on to next step of 
//    saving the track

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

  if(!getalts.reqlist) getalts.reqlist = [] ;
  reqlist = getalts.reqlist ;

  if(thresh<0) s0 = doneaction ;
  else if(doneaction!=undefined) getalts.action = doneaction ; // so null is ok
  // if getalts is called while alts are still being got, all we do is tighten 
  // the threshold and make altcallback override doneaction
  if(reqlist.length>0) 
  { if(altthresh==null||thresh<altthresh) altthresh = thresh ; return ; }
  else if(thresh==null) return ; 

  if(!elevator) elevator = new google.maps.ElevationService ;

  if(altthresh!=null&&altthresh<thresh) thresh = altthresh ;
  p = flatten(segments) ; 

  if(thresh<0) // ie. we're doing a linear regression
  { reqlist = getalts.reqlist = new Array(p.length) ; 
    dotimes = 0 ; 
    for(n=j=i=0;i<p.length;i++) if(p[i].h) 
    { if(p[i].t) n += 1 ; reqlist[j++] = i ; }
    reqlist.length = j ; 

    if(n>=reqlist.length/2&&n>=2) for(dotimes=1,j=i=0;i<reqlist.length;i++) 
      if(p[reqlist[i]].t) reqlist[j++] = reqlist[i] ; 
    reqlist.length = j ; 

    if(reqlist.length>500) 
    { for(i=0;i<500;i++) 
        reqlist[i] = reqlist[Math.floor(i*(reqlist.length-1)/499)] ;
      reqlist.length = 500 ; 
    }
    for(loxpos=new Array(reqlist.length),i=0;i<reqlist.length;i++) 
      loxpos[i] = p[reqlist[i]].pos ; 
  }
  else
  { for(flag=npts=i=0;i<p.length;i=j)
    { if(p[i].h!=null) { j = i+1 ; continue ; }
      for(j=i+1;j<p.length&&p[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<p.length) j += 1 ; 
      n = j - i ; 
      if(npts+n<=500) { npts += n ; reqlist.push([i,j]) ; }
      else if(reqlist.length) // 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) 
    { getalts.reqlist = [] ; 
      altthresh = altcallback = null ; 
      if(getalts.action) { getalts.action() ; getalts.action = null ; }
      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[p[k].sel[0]].pts[p[k].sel[1]] ; 
    p = 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*(npts-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 p 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

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

  function doelevations(results,status,m,c)
  { // assume that the results come in sequence, ie. correspond to xpending[0]
    var d0,d1,dn,reqno,i,j,k,l,n,err,d,D,sx,sy,sxx,sxy,sy,x,y,j0,j1 ;

    if(thresh!=-3) // check response is as expected
    { if(status===google.maps.ElevationStatus.OVER_QUERY_LIMIT) alert(L.goql) ;
      else if(status===google.maps.ElevationStatus.INVALID_REQUEST)
        alert(L.ginv) ;  
      else if(status===google.maps.ElevationStatus.REQUEST_DENIED)
        alert(L.gden) ;  
      else if(status===google.maps.ElevationStatus.UNKNOWN_ERROR)
        alert(inject(L.gunk,status)) ;  
      else if(status!==google.maps.ElevationStatus.OK) alert(L.gcer) ;  
      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) abend(L.gcor) ; 
    }

    if(thresh<0)
    { // set sel as the independent variable x for regression
      for(n=i=0;i<p.length;i++) if(p[i].t) n += 1 ; 
      if(dotimes) 
      { // set sel to time whenever available, to null else
        for(i=0;i<p.length;i++) 
          if(p[i].t) p[i].sel = p[i].t.getTime() ;
          else p[i].sel = null ; 
        // linearly extrapolate times at ends when absent
        for(j0=0;!p[j0].sel;j0++) ; 
        for(j1=p.length-1;!p[j1].sel;j1--) ; 
        d0 = p[j0].d ;
        d1 = p[j1].d ;
        for(i=0;i<p.length;i++)
          if(i==j0) i = j1 ; 
          else 
        { d  = p[i].d ;
          p[i].sel = ( (d1-d)*p[j0].sel + (d-d0)*p[j1].sel ) / ( d1-d0 ) ; 
        }
        // linearly interpolate times when absent
        for(j1=j0+1;!p[j1].sel;j1++) ; 
        for(i=0;i<p.length;i++) if(!p[i].sel)
        { if(reqlist[j1]<i)
          { for(j=j1+1;j<p.length&&!p[j].sel;j++) ;
            if(j<p.length) { j0 = j1 ; j1 = j ; }
          }
          d0 = p[j0].d ;
          d  = p[i].d ;
          d1 = p[j1].d ;
          p[i].sel = ( (d1-d)*p[j0].sel + (d-d0)*p[j1].sel ) / ( d1-d0 ) ; 
        }
      }
      else for(i=0;i<p.length;i++) p[i].sel = p[i].d ;

      // regress
      if(thresh==-1)
      { for(sx=sy=sxx=sxy=i=0;i<reqlist.length;i++) 
        { j = reqlist[i] ;
          y = ( results[i].elevation -= p[j].h ) ; // google correction
          sy += y ; 
          sx += ( x = p[j].sel ) ;
          sxy += x * y ; 
          sxx += x * x ;
        }
        m = (sxy-sx*sy/reqlist.length) / (sxx-sx*sx/reqlist.length) ; 
        c = (sy-m*sx) / reqlist.length ;
      }
      else if(thresh==-2)
      { for(sy=m=i=0;i<reqlist.length;i++) 
          sy += ( results[i].elevation -= p[reqlist[i]].h ) ; 
        c = sy / reqlist.length ;
      }
      for(i=0;i<p.length;i++) 
      { y = segments[0].pts[i].h ;
        if(y!=null) segments[0].pts[i].h = y + m*p[i].sel + c ; 
      }
      getalts.reqlist = [] ;
      if(thresh!=-3) done([thresh==-2?'googleadd':'googlereg',s0,m,c]) ; 
      if(action) action(npts) ; 
      return ;
    }

    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 += lox[k].delta ; 
        dn = lox[loxind[i+1]].h ; 
        d0 = lox[loxind[i]].h ; 
        for(d=0,k=loxind[i];k<loxind[i+1]-1;k++) 
        { d += lox[k].delta ; 
          lox[k+1].h = ( d*dn+(D-d)*d0 ) / D ; 
        }
      }
    }
    getalts.reqlist = [] ; 
    if(action) action(npts) ; 
    getalts(segments,thresh,action) ; 
  } 
  if(thresh==-3) doelevations(null,null,m,c) ; 
  else elevator.getElevationForLocations( {locations:loxpos},doelevations ) ;
}
/* -------------------------------------------------------------------------- */

function getbounds(segment)
{ var bounds = { minlon:null } ; 
  recurse(segment,'getbounds',bounds) ; 
  return new google.maps.LatLngBounds
                      ( new google.maps.LatLng(bounds.minlat,bounds.minlon) ,
                        new google.maps.LatLng(bounds.maxlat,bounds.maxlon) ) ;
}
/* -------------------------------------------------------------------------- */

function promoteprops(route,base)
{ var field , flag = 0 ; 

  if(route.list&&base.list&&route.list!=base.list)
    abend(L.inconsistentlists + ' ' + route.list + ' vice ' + base.list) ; 

  for(field in promotable) 
    if( ( promotable[field]<20&&route[field]&&!base[field] )
     || ( promotable[field]>=20&&route[field].length&&!base[field].length ) )
  { flag |= 1 << promotable[field] ; base[field] = route[field] ; }

  return flag ;
}
/* -------------------- load a track and update state ----------------------- */

// ovr =-1 loads a photolist (filedialogue only)
// ovr = 0 loads a track as a new segment
// ovr = 1 is the normal case (loading a new track)
// ovr = 2 updates a track in an index

// note: if more than one file is being loaded, loadtrack is called once for  
// each file, so it does not need to deal with multiple input files (which might
// be a mixture of tracks and indexes)

function loadtrack(response,filename,ovr,origintype,prefs,loadflags) 
{ var xmldoc,newseg,i,j,k,routeno,rind,oldroute,col,hues=[],hue,q,flag,updseg ; 
  var opts,res,n,s,lev0parms,dooptim ; 
  var extn = filename.substring(filename.length-4).toLowerCase() ;
  var lname = [ ' '+L.aroute , ' '+L.anindex , ' '+L.ameta ] ; 
  var shortname = abbreviate(filename)[0] ; 
  if(ovr=='add'&&segments.length==0) abend('Logic error') ; 
  if(ovr=='refresh') { updseg = origintype ; origintype = 'refresh' ; }

  function callindexify(seg)
  { // the set of optimisations to perform depends on whether we are loading a 
    // track or an index, whether (if a track) it has already been optimised, 
    // and whether we are adding it to an index or to a metaindex.
    var parms = [] ; 
    if(seg.level==0&&segments[0].level) 
    { if(!seg.optim) parms.push(defparms) ; 
      if(segments[0].level==2) parms.push(indparms) ; 
    }
    if(segments[0].level==1) parms.push(indparms) ; else parms.push(metaparms) ;
    return indexify(seg,parms) ; 
  }

  // parse response
  if(response.length<9||(extn!='.fit'&&response.substring(0,9)=='** Error:')) 
    abend(response) ;
  if(extn!='.fit'&&extn!='.csv') 
    xmldoc = parser.parseFromString(response,"application/xml") ;
  if(extn=='.tcx') newseg = readtcx(xmldoc,filename) ; 
  else if(extn=='.gpx') newseg = readgpx(xmldoc,filename) ; 
  else if(extn=='.rte') newseg = readrte(xmldoc.documentElement,filename) ; 
  else if(extn=='.kml') newseg = readkml(xmldoc,filename) ; 
  else if(extn=='.fit') 
  { newseg = readfit(response) ; 
    if(!newseg.title) newseg.title = shortname ; 
    newseg.srcid = shortname ; 
  }
  else if(extn=='.csv') 
  { newseg = readcsv(response) ; newseg.title = shortname ; }
  else newseg = readgoogle(xmldoc) ;

  if(!newseg.pts.length) { alert(inject(L.nodata,shortname)) ; return null ; }
  if(newseg.level==0&&(!prefs||prefs.optim)&&!newseg.optim)
    newseg.pts = decimate(newseg.pts) ; 
  recurse(newseg,'deltas') ; // compute deltas
  newseg.origin = [ filename , origintype ] ; 
  newseg.filename = filename ; 

  // don't allow missing altitudes in url load or in tracks added to indexes
  if(origintype=='uri'||(segments.length>0&&segments[0].level>0))
    if(testflag==0&&newseg.level==0) for(i=0;i<newseg.pts.length;i++) 
      if(newseg.pts[i].h==null||newseg.pts[i].h==undefined) 
        abend(L.missingurialt) ; 

  // check legality of load level for addition
  if(ovr=='add') if( newseg.level>segments[0].level || newseg.level==2
                || ( newseg.level==0&&segments[0].level==2) )
  { alert(inject2(L.addxtoy,lname[newseg.level],lname[segments[0].level])) ; 
    return null ; 
  }
  // check legality of load level for overwriting
  if(ovr=='load'&&segments.length&&((segments[0].level>0)!=(newseg.level>0)))
  { alert(inject2(L.addxtoy,lname[newseg.level],lname[segments[0].level])) ; 
    return null ; 
  }

  if(ovr=='load') // initialise: don't do this earlier in case the read fails
  { if(imginfo.type=='tcx') imginfo = {} ; 
    else if(imginfo.status=='ready') imginfo.status = '?' ; 
    // if a photolist has been loaded, it is not unloaded when a new track 
    // overwrites the original, since it may still be useful: if a list is not
    // incorporated in the new track, the status of the old list is set to  
    // 'questionable' and the user is prompted whether to keep it when he or she
    // comes to add an image
    if(sel&&sel.marker) sel.marker.setMap(null) ; 
    sel = { marker:null, orientation: null } ; 
    actions = [] ; 
    unsavedchanges = [] ; 
    nactions = dragging = 0 ; 
    routeprops = new routetype() ;
    for(i=0;i<segments.length;i++) 
    { recurse(segments[i],'obliterate') ; disconnect(segments[i]) ; }
    segments = [] ; 
    hues = [] ; 
  } 
  else 
  { if(segments.length&&segments[0].level>0) newseg.tlink = filename ; 
    for(routeno=0;routeno<segments.length;routeno++)  // enumerate the hues
    { if(segments[routeno].level) k = segments[routeno].pts.length ; 
      else { k = 1 ; hue = segments[routeno].hue ; }
      for(i=0;i<k;i++) 
      { if(segments[routeno].level) hue = segments[routeno].pts[i].hue ; 
        for(j=0;j<hues.length&&(hues[j][0]!=hue[0]||hues[j][1]!=hue[1]);j++) ;
        if(j==hues.length) hues.push(hue) ;
      }
    }
  }

  // refreshing a track or index in an index or metaindex
  if(ovr=='refresh') 
  { oldroute = segments[0].pts[updseg] ;
    recurse(oldroute,'obliterate') ; 
    segments[0].pts[updseg] = newseg = callindexify(newseg) ; 
    newseg.tmode = oldroute.tmode ; 
    if(newseg.level) { newseg.hue = oldroute.hue ; genshades(newseg) ; }
    else newseg.colour = oldroute.colour ; 
    if(newseg.level==1) recurse(newseg,'arrows') ;
    actions[nactions++] = [ 'refresh' , updseg , oldroute ] ;
  }

  // loading an index or metaindex
  else if(newseg.level&&ovr!='add') 
  { setdomtitle(newseg.title?newseg.title:L.untitledroute) ; 
    segments = [newseg] ;
    // assign colours
    hues = extendhues(newseg.pts.length,[]) ; // hues is list of [r,g,b] triples
    rind = assigncolours(hues,getprox(newseg)) ; // rind is a subset of hues
    for(i=0;i<newseg.pts.length;i++) 
    { newseg.pts[i].colour = hexify(newseg.pts[i].hue=rind[i]) ;
      if(newseg.pts[i].level) genshades(newseg.pts[i]) ; 
    }
    if(newseg.level==1) recurse(newseg,'arrows') ;
    actions[nactions++] = [ 'load' , 0 , newseg.title , null ] ;
  }

  // loading a new track or adding it as a new segment
  else if(newseg.level==0&&(segments.length==0||segments[0].level==0))
  { actions[nactions++] = [ ovr , segments.length , newseg.title , null ] ;

    if(newseg.optim||(loadflags&&loadflags.indexOf('n')>=0)) dooptim = 0 ; 
    else if(loadflags&&loadflags.indexOf('o')>=0) dooptim = 1 ; 
    else if(prefs&&!prefs.optim) dooptim = 0 ; 
    else dooptim = 1 ; 

    if(dooptim) 
    { if(prefs) lev0parms = optimparms(prefs.detail,prefs.maxsep) ; 
      else lev0parms = defparms ;
      res = optimise(newseg.pts,lev0parms) ; 
      if((n=res.ind.length)<newseg.pts.length/2)
      { optimaccept(res,newseg,lev0parms,segments.length) ; 
        optimmerge(newseg,res) ; 
        q = pen2detail(lev0parms.wppenalty) ;
        telltale(inject(L.optimdetail,q.toFixed(0))) ; 
      }
    }

    newseg.hue = col = extendhues(1,hues)[0] ; 
    q = 0.5 * ( 2 - col[1]/255 ) ;
    newseg.colour = hexify([q*col[0],q*col[1],q*col[2]]) ; 
    flag = promoteprops(newseg,routeprops) ; 
    if(flag&(1<<promotable.list)) getlist(newseg.list,'tcx') ; 
    if(flag&(1<<promotable.title)) setdomtitle(newseg.title) ; 
    else if(ovr=='load') setdomtitle(L.untitledroute) ; 
    segments.push(newseg) ; 
  }

  // adding a track or index to an index or metaindex
  else if(segments.length&&segments[0].level&&ovr=='add') 
  { actions[nactions++] = [ ovr,segments[0].pts.length,newseg.title,null ] ;
    newseg = callindexify(newseg) ; 
    newseg.hue = col = extendhues(1,hues)[0] ; 
    q = 0.5 * ( 2 - col[1]/255 ) ;
    newseg.colour = hexify([q*col[0],q*col[1],q*col[2]]) ; 
    if(newseg.level) genshades(newseg) ; 
    segments[0].pts.push(newseg) ; 
  }
  else alert('logic error') ; 
  return newseg ; 
}
/* --------------------------------- flatten -------------------------------- */

function flatten(segments)
{ var p,npoint,d,s0,s1,len,pos,pts,opos ;
  for(npoint=s0=0;s0<segments.length;npoint+=segments[s0].pts.length,s0++) ;
  p = new Array(npoint) ;

  for(d=npoint=s0=0;s0<segments.length;opos=pos,s0++)
    for(pts=segments[s0].pts,len=pts.length,s1=0;s1<len;s1++) 
  { pos = pts[s1].pos ;
    if(s1>0) d += pts[s1-1].delta ; else if(npoint) d += dist(opos,pos) ;
    p[npoint++] = 
      { pos:pos , h:pts[s1].h , t:pts[s1].t , sel:[s0,s1] , d:d } ;
  }
  return p ; 
}
/* -------------------------------------------------------------------------- */

function genarrows(seg)
{ var len=seg.pts.length,i,tdist,nar,d,tgt0,tgt1,pt,ptscr,fom,x,icon,narrow ;
  for(tdist=i=0;i<len-1;i++) tdist += seg.pts[i].delta ;
  narrow = Math.floor(0.5+tdist/10000) ; // one per 10km
  if(!narrow&&tdist>4000) narrow = 1 ;   // but at least one if the route is 4km
  if(!narrow) { seg.arrows = [] ; return ; }
  seg.arrows = new Array(narrow) ; 
  icon = { path: "M 6 9  0 15  6 0  12 15 z",
           fillColor: seg.colour,
           fillOpacity: 1,
           strokeColor: seg.colour,
           strokeWeight: 0,
           anchor: new google.maps.Point(6,6),
           rotation: 0,
           scale: 1,
           clickable: false } ;

  for(nar=d=s1=i=0;i<narrow;i++)
  { tgt0 = ( tdist / narrow ) * i ; 
    tgt1 = ( tdist / narrow ) * (i+1) ; 
    for(ptscr=-1;s1<len&&d<tgt1;d+=seg.pts[s1].delta,s1++) 
    { x = d + seg.pts[s1].delta/2 ;
      fom = seg.pts[s1].delta * (x-tgt0) * (tgt1-x) / (1+tgt1-tgt0) ;
      if(fom>ptscr) { ptscr = fom ; pt = x ; }
    }
    if(ptscr>0) seg.arrows[nar++] = 
                  { icon: icon , offset: (100*pt/tdist).toFixed(8) + '%' } ;
  }
  seg.arrows.length = nar ; 
}
/* -------------------------------- parsedesc ------------------------------- */

function parsedesc(d,val,f,origin)
{ var i,dd=d,s,tags=['i','I','b','B','s','S','u','U'],slen,sty,sind,len ;

  function parsehtml(d,s,f)
  { var i,j,idash,len=s.length,e,href=null,plus,ind,str ; 
    for(href=null,slen=i=0;i<len-7;i++) 
    { if(s.charAt(i)!='<') continue ; 
      if( ( s.charAt(i+2)!='>'||tags.indexOf(s.charAt(i+1))<0)
       && ( s.charAt(i+2)!=' '||(s.charAt(i+1)!='a'&&s.charAt(i+1)!='A') ) ) 
        continue ; 
      if(s.charAt(i+1)!='a'&&s.charAt(i+1)!='A') idash = i+3 ; 
      else
      { for(idash=i+3;idash<len-4&&s.charAt(idash)==' ';idash++) ;
        if(idash==len-4||s.substring(idash,idash+5).toLowerCase()!='href=') 
          continue ; 
        for(idash+=5,j=idash;j<len-4&&s.charAt(j)!='>';j++) ;
        if(j==len-4) continue ; 
        if(s.charAt(idash)=='"')
        { if(j-1<=idash||s.charAt(j-1)!='"') continue ;
          href = s.substring(idash+1,j-1) ; 
        }
        else href = s.substring(idash,j) ; 
        idash = j+1 ; 
      }
      for(j=idash;j<len-3;j++)
        if( s.charAt(j)=='<' && s.charAt(j+1)=='/' && s.charAt(j+3)=='>'
         && s.charAt(j+2).toLowerCase()==s.charAt(i+1).toLowerCase()) break ; 
      if(j==len-3||j==idash) continue ; 

      // add text preceding the tag
      if(i) { domadd(d,s.substring(0,i)) ; slen += i ; }

      // parse the link
      if(href)
      { if(href.charAt(0)!='+') plus = 0 ; 
        else { href = href.substring(1) ; if(segments[0].level==0) plus = 1 ; }
        href = trackref(origin,href,plus?0:2) ; 
      }

      function loaderfactory(href) 
      { return function() 
        { trackuri = href ; 
          readuri(function(r) { render(r,href,'urilink',null,'add');}) ; 
        }
      }

      // generate the element under the tag: create a span if an <a> cannot
      // be rendered as a link
      if(idash>i+3&&(plus||!href)) e = document.createElement('span') ; 
      else e = document.createElement(s.charAt(i+1)) ; 

      slen += parsehtml(e,s.substring(idash,j)) ; 

      if(href&&plus) 
      { domadd(e,' ⊞') ; 
        e.setAttribute('style','cursor:pointer;color:#0000bd') ; 
        e.onclick = loaderfactory(href) ; 
      }
      else if(href)
      { domadd(e,' ') ; 
        e.appendChild(newtabdiv()) ;
        e.setAttribute('href',href) ;
        e.setAttribute('target','_blank') ; 
      }
      d.appendChild(e) ; 

      // process the text following the tag
      slen += parsehtml(d,s.substring(j+4),f) ; 
      return slen ; 
    }

    // if we get here, then we have a final top-level chunk
    slen += s.length ; 
    if(f) { s += ' [' ; slen += 3 + L.edit.length ; }
    if(s) domadd(d,s) ; 
    if(f) { d.appendChild(genclickfn(f,L.edit)) ; domadd(d,']') ; }
    return slen ; 
  }
  /* ------------------------------------------------------------------------ */

  for(i=val.length;i>0&&(val.charAt(i-1)=='\n'||val.charAt(i-1)==' ');i--) ; 
  val = val.substring(0,i) ; 

  for(len=i=0;val.length;i++) 
  { for(s=0;s<val.length&&(val.charAt(s)==' '||val.charAt(s)=='\n');s++) ; 
    if(s==val.length) break ; // all white space
    for(s=0;s<val.length&&val.charAt(s)=='\n';s++) ; 
    val = val.substring(s) ; 
    sind = val.indexOf('\n') ; 
    if(i) 
    { dd = document.createElement('div') ; 
      if(s) sty = 'margin-top:'+(1+3*s)+'px;' ; else sty = '' ;
      if(sind<0) sty += 'margin-bottom:3px' ;
      if(sty) dd.setAttribute('style',sty) ;
    }
    if(sind<0) { len += parsehtml(dd,val,f) ; val = '' ; }
    else 
    { len += parsehtml(dd,val.substring(0,sind)) ; 
      val = val.substring(sind) ; 
    }
    if(i) d.appendChild(dd) ; 
  }
  return len ;
}
/* ---------------------------------- trackref ------------------------------ */

// a track has been specified as href in a route loaded from origin: we want an 
// absolute uri for it, prefixed by the routemaster invocation iff fullcall

// fullcall is 0 for additive links, 2 for non-additive links in route 
// descriptions, and 1 in all other cases

function trackref(origin,href,fullcall)
{ var ind=-1,str ; 
  if(href&&fullcall==2&&!isgps(href)) return href ; // link is not to a gps trk

  if(!href||!absuri(href))
  { if( !origin || !origin[1] 
     || (origin[1].substring(0,3)!='uri'&&origin[1]!='refresh') ) return null ; 
    if(href) href = reluri(origin[0],href) ; else href = origin[0] ;
  }
  else ind = href.indexOf('?track=') ; 

  if(fullcall&&ind<0) 
  { ind = document.location.href.indexOf('?') ; 
    if(ind<0) str = document.location.href ;
    else str = document.location.href.substring(0,ind) ;
    href = str + '?track=' + href ; 
  }
  else if(!fullcall&&ind>=0)
  { href = href.substring(ind+7) ; 
    ind = href.indexOf('&') ; 
    if(ind>=0) href = href.substring(0,ind) ; 
  }
  return href ; 
}
/* -------------------------------------------------------------------------- */

function pluralise(str,n)
{ if(n==1) return str[0] ; else return inject(str[1],n) ; }

function caps(x) { return x.charAt(0).toUpperCase() + x.substring(1) ; }

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

var icons = 
{ // coursepoint icons
  list: 
  [ // Generic
    { 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),
      xmid:5.5
    } ,
    // Sharp left
    { path: "M 18.5 20.5  L 16.4 7  A 1 1 0 0 0 14.8 7.5  "+
            "L 10.3 12.3  12.6 14.6 "+
            "5.6 13.6  4.6 6.6  6.9 8.9  15 2.8  A 3.3 3.3 0 0 1 19.8 6   z",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(18.5,20.5),
      xmid:13
    } ,
    // Left
    { 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),
      xmid:13
    } ,
    // Slight left
    { path: "M  14.5 20.5  L 12.8 12.8  A 7 7 0 0 0 10.3 9   L 7.2 7  4.4 9.8 "+
            "5.9 2.3  13.4 0.8  10.6 3.6  14.5 7.9   A 5 5 0 0 1 15.9 11.3   z",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(14.5,20.5),
      xmid:12
    } ,
    // Straight
    { 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),
      xmid:7.5
    } ,
    // Slight right
    { path: "M 7.5 20.5  L 9.2 12.8   A 7 7 0 0 1 11.7 9   L 14.8 7  17.6 9.8 "+
            "16.1 2.3  8.6 0.8  11.4 3.6  7.5 7.9   A 5 5 0 0 0 6.1 11.3   z",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(7.5,20.5),
      xmid:10
    } ,
    // Right
    { 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),
      xmid:8
    } ,
    // Sharp right
    { path: "M  3.5 20.5  L 5.6 7  A 1 1 0 0 1 7.2 7.5  "+
            "L 11.7 12.3  9.4 14.6 "+
            "16.4 13.6  17.4 6.6  15.1 8.9  7 2.8  A 3.3 3.3 0 0 0 2.2 6   z",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(3.5,20.5),
      xmid:9
    } ,
    // Danger
    { 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   5 6  A 4 4 0 1 1 12 6  L 8.5 14.5" ,
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(8.5,21.5),
      xmid:8.5 
    } ,
    // Food
    { 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),
      xmid:3.5
    } ,
    // water
    { path: "M 2.5 8.5  L 7.5 8.5   A 2 2 0 0 1 9.5 6.5 L  9.5 3.5 7.5 3.5" +
            "A 1 1 0 0 1 7.5 1.5  L 13.5 1.5   A 1 1 0 0 1 13.5 3.5  " +
            "L 11.5 3.5  11.5 6.5 A 2 2 0 0 1 13.5 8.5   L 16.5 8.5 " + 
            "A 5 5 0 0 1 21.5 13.5  L 21.5 15.5 18.5 15.5 18.5 13.5 " + 
            "A 2 2 0 0 0 16.5 11.5 L 13.5 11.5 A 5 5 0 0 1 7.5 11.5 "+
            "L 2.5 11.5 z" +
            "M 20 17.5     A 1.5 1.5 0 0 1 20 20.5  A 1.5 1.5 0 0 1 20 17.5" ,
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(20,22),
      xmid:12
    } ,
    // summit
    { path: "M 2.5 16.5  Q 10.5 2 18.5 16.5 Z "+
            "M 10.5 20.5 L 10.5 1.5 15 3.5 10.5 5.5 " ,
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(10.5,21),
      xmid:10.5
    } ,
    // valley
    { path: "M 2.5 16.5  L 2.5 6.5 Q 10.5 23 18.5 6.5 L 18.5 16.5 Z "+
            "M 10.5 20.5 L 10.5 1.5 15 3.5 10.5 5.5 ", 
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(10.5,21),
      xmid:10.5
    } ,
    // first aid
    { path: "M 1.5 7.5 L 7.5 7.5  7.5 1.5  13.5 1.5  13.5 7.5  19.5 7.5 "+
            "19.5 13.5  13.5 13.5  13.5 17.5  10.5 21.5  7.5 17.5  7.5 13.5 "+
             "1.5 13.5 Z ",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(10.5,22),
      xmid:10.5
    } ,
    // info
    { path: "M 9 6   A 2.5 2.5 0 0 1 9 1   A 2.5 2.5 0 1 1 9 6   M 9 21.5  "+
            "A 2 2 0 0 0 7 19.5   L 3.5 19.5   3.5 19.5  3.5 17.5  5 17.5   "+
            "A 1.5 1.5 0 0 0 6.5 16   L 6.5 11  A 1.5 1.5 0 0 0 5 9.5  "+
            "L 3.5 9.5   3.5 7.5  11.5 7.5   11.5 16  A 1.5 1.5 0 0 0 13 17.5 "+
            "L  14.5 17.5  14.5 19.5    11 19.5 A 2 2 0 0 0 9 21.5",
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(9,22),
      xmid:9
    } ,
    // obstacle
    { path: "M 7 14.5  10 14.5  15 8.5  12 8.5 z" +
            "M 1.5 17.5  3.5 17.5  3.5 14.5  1.5 14.5 z" +
            "M 1.5 8.5  3.5 8.5  3.5 5.5  1.5 5.5 z" +
            "M 3.5 20.5  3.5 3.5  5 0.5  6.5 3.5  6.5 20.5 z" +
            "M 6.5 17.5  15.5 17.5  15.5 14.5  6.5 14.5 z" +
            "M 6.5 8.5  15.5 8.5  15.5 5.5  6.5 5.5 z" +
            "M 15.5 20.5  15.5 3.5  17 0.5  18.5 3.5  18.5 20.5 z" +
            "M 18.5 17.5  20.5 17.5  20.5 14.5  18.5 14.5 z" +
            "M 18.5 8.5  20.5 8.5  20.5 5.5  18.5 5.5 z" +
            "M 10 17.5  11 21 12 17.5 z" ,
      fillColor: '#FCDFFF',
      fillOpacity: 0.7,
      strokeColor: 'purple',
      strokeWeight: 1.5,
      anchor: new google.maps.Point(11,22),
      xmid:11
    } 
] ,
  names: [ 'Generic' , 'Sharp left' , 'Left' , 'Slight left' , 
           'Straight' , 'Slight right' , 'Right' , 'Sharp right' , 
           'Danger' , 'Food' , 'Water' , 'Summit' , 
           'Valley' , 'First Aid' , 'Info' , 'Obstacle'
         ] ,
  tcxnames: [ 'Generic' , 'Left' , 'Left' , 'Left' , 
           'Straight' , 'Right' , 'Right' , 'Right' , 
           'Danger' , 'Food' , 'Water' , 'Summit' , 
           'Valley' , 'First Aid' , 'Generic' , 'Generic'
         ] ,
  fitnames: [ 0,20,6,19, 8,21,7,22, 5,4,3,1, 2,9,53,46 ] ,

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

var oeuvres = 
  [ [ 'turn-slight-left' ,  'Slight left' , L.slightl ] ,
    [ 'turn-sharp-left' ,   'Sharp left'  , L.sharpl ] ,
    [ 'uturn-left' ,        'Left'        , L.uturnl ] ,
    [ 'turn-left' ,         'Left'        , L.turnl ] ,
    [ 'ramp-left' ,         'Left'        , L.rampl ] ,
    [ 'fork-left' ,         'Slight left' , L.forkl ] ,
    [ 'roundabout-left' ,   'Left'        , L.raboutl ] ,
    [ 'turn-slight-right' , 'Slight right', L.slightr ] ,
    [ 'turn-sharp-right' ,  'Sharp right' , L.sharpr ] ,
    [ 'uturn-right' ,       'Right'       , L.uturnr ] ,
    [ 'turn-right' ,        'Right'       , L.turnr ] ,
    [ 'ramp-right' ,        'Right'       , L.rampr ] ,
    [ 'fork-right' ,        'Slight right', L.forkr ] ,
    [ 'roundabout-right' ,  'Right'       , L.raboutr ] ,
    [ 'straight' ,          'Straight'    , L.straight ] ,
    [ 'merge' ,             'Generic'     , L.merge ] ] ;

function xmlfloat(x) { return parseFloat(x.textContent) ; }

function isvaliddate(d) 
{ if(Object.prototype.toString.call(d)!=="[object Date]") return false ;
  else return !isNaN(d.getTime()) ;
}
/* ------------------------------- pts 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 label 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. label null, map null, handlers inactive, but marker non-null
// o. label non-null, map null, handlers inactive, marker non-null
// o. label non-null, map non-null, handlers active, marker non-null
// the state in which label 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 pttype(pos,h,t)
{ if(!pos) pos = null ; 
  if(h==undefined) h = null ; // altitude (m)
  if(t==undefined) t = null ; // a Date object
  this.pos = pos ;            // a google maps pos
  this.h = h ; 
  this.marker = this.photomarker = this.label = this.t = this.selfunc = null ;
  this.map = null ; 
  if(isvaliddate(t)) this.t = t ; 
  this.photo = [] ;
  this.caption = '' ; 
  this.delta = null ;  
  this.clickhandler = this.photohandler = null ; 
}
// member functions
pttype.prototype.geticon = function()
{ var ind = icons.names.indexOf(this.label) ; 
  return icons.list[ind<0?0:ind] ;
} ;
pttype.prototype.setlabelmap = function(m,seller) 
{ if(!this.label) m = null ; 
  this.selfunc = seller ;
  this.map = m ; 
  if(!m&&!this.marker) return ;
  this.marker.setMap(m) ; 
  if(!m&&this.clickhandler)
  { google.maps.event.removeListener(this.clickhandler) ;
    this.clickhandler = this.selfunc = null ; 
  }
  if(m&&!this.clickhandler)
  { if(seller) this.clickhandler = this.marker.addListener('click',seller) ; }
} ;
pttype.prototype.setphotomap = function(m,seller) 
{ if(this.photo.length==0) m = null ; 
  this.selfunc = seller ;
  this.map = m ; 
  if(m==null&&this.photomarker==null) return ;
  if(this.photomarker) 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) ;
} ;
pttype.prototype.setlabel = function(l,c) 
{ this.label = l ; 
  if(c) this.caption = c ; else this.caption = null ; 
  if(!l) { if(this.marker) this.setlabelmap(null,null) ; return ; } 
  if(!this.marker) 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) ; }
} ;
pttype.prototype.setphoto = function(ind,p) 
{ 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) ; }
} ;
pttype.prototype.addphoto = function(p) 
{ var i ; 
  if(p.length==0) return ; 
  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)) ;
} ;
pttype.prototype.setpos = function(p) 
{ if(p) this.pos = p ; 
  if(this.label!=null) this.marker.setPosition(this.pos) ; 
  if(this.photo.length>0) this.photomarker.setPosition(this.pos) ; 
} ;
pttype.prototype.setmap = function(m,seller) 
{ this.setlabelmap(m,seller) ; this.setphotomap(m,seller) ; } ;

pttype.prototype.changelabel = function(l) 
{ this.label = l ; this.marker.setIcon(this.geticon()) ; } ;

pttype.prototype.clone = function()
{ var c = clone(this) ; 
  c.photo = clone(this.photo) ; 
  c.setlabel(this.label,this.caption) ; // sets marker
  c.setpos(this.pos) ; 
  c.setmap(this.map,this.selfunc) ; 
  return c ; 
}
function addlabel(pts,pos,label,caption) 
{ var i,pt,mindist ; 
  for(pt=null,i=0;i<pts.length;i++) 
    if(!pt||dist(pos,pts[i].pos)<mindist) 
  { mindist = dist(pos,pts[i].pos) ; pt = pts[i] ; } 
  if(pt) pt.setlabel(label,caption) ;
}
/* ------------------------------ props structure --------------------------- */

function routetype()
{ this.desc = this.title = this.list = this.filename = this.stats = null ;
  this.tlink = this.index = this.stars = this.srcid = this.origin = null ; 
  this.date = this.optim = this.info = this.arrows = this.bounds = null ; 
  this.line = this.dots = this.dothandler = this.indexmode = this.tmode = null ;
  this.colour = this.hue = "red" ; 
  this.level = 0 ; 
  this.photo = [] ;
  this.smallphoto = [] ;
  this.pts = [] ; 
  this.shades = [] ;
}
routetype.prototype.clone = function()
{ var c = clone(this) , i ; 
  c.photo = clone(this.photo) ; 
  c.smallphoto = new Array(this.smallphoto.length) ; 
  for(i=0;i<this.smallphoto.length;i++) 
    c.smallphoto[i] = clone(this.smallphoto[i]) ; 
  return c ; 
}
/* -------------------------------------------------------------------------- */

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

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

function getpt(node,tags)
{ var lat=null,lon=null,alt=null,time=null,j,nodeno,nodes,photo=[],tag,valid ;
  var pos=null,ind,validalt,caption=null,label=null,truelabel=null,dt=null ; 

  if(!tags||tags.mode=='gpx')
  { lat = parseFloat(node.getAttribute('lat')) ; 
    lon = parseFloat(node.getAttribute('lon')) ; 
    if(lat&&lon) pos = new google.maps.LatLng(lat,lon) ;
  }
  nodes = node.childNodes ;

  if(!tags)
  { if(!pos) return null ; 
    alt = parseFloat(node.getAttribute('h')) ; 
    dt = parseFloat(node.getAttribute('dt')) ; 
    if(time=node.getAttribute('t')) time = new Date(time) ; 
    label = normalise(node.getAttribute('label')) ; // simplify white space
    caption = normalise(node.getAttribute('caption')) ;
    for(nodeno=0;nodeno<nodes.length;nodeno++) if(nodes[nodeno].nodeName=='img') 
      if(nodes[nodeno].getAttribute('type')=='pix')
        photo.push(normalise(nodes[nodeno].textContent)) ;
    pt = new pttype(pos,alt,time) ; 
    pt.addphoto(photo) ; 
    if(label) pt.setlabel(label,caption) ; 
    else if(caption) pt.setlabel('Generic',caption) ; 
    return { pt:pt , dt:dt } ;
  }

  else 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(normalise(node.textContent)) ; 
    else if(node.nodeName=='Position') 
      pos = getlatlong(node,'LatitudeDegrees','LongitudeDegrees') ;
    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.match(/\S+/g) ;
      else if(tag==tags.vtime) valid = 0 ;
      else if(tag==tags.vel) validalt = 0 ;
      else if(tag==tags.truelabel) 
        truelabel = normalise(node.childNodes[j].textContent) ;
    }
    else if(node.nodeName==tags.label) 
    { label = normalise(node.textContent) ;
      if(label.substring(0,4)=='Bear') label = 'Slight' + label.substring(4) ; 
    }
    else if(node.nodeName==tags.caption) caption = normalise(node.textContent) ;
  }
  if(!pos) return null ; 
  if(!isvalidnum(alt)) validalt = 0 ; 
  pt = new pttype(pos,validalt?alt:null,valid?time:null) ; 
  pt.addphoto(photo) ;
  return { pt:pt , caption:caption , label:(truelabel?truelabel:label) } ;
}
/* -------------------------------------------------------------------------- */

function getprops(nodes,tags,xmlfile)
{ var props = new routetype() , node,i,id,field,txt,item,srcset,slen ;

  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 , 
            origlen: parseInt(node.getAttribute('from')) ,
            len:     parseInt(node.getAttribute('to')) ,
            parms:   { tol: parseFloat(node.getAttribute('tol')) ,
                       maxsep: parseFloat(node.getAttribute('maxsep')) ,
                        wppenalty: parseFloat(node.getAttribute('wppenalty')) ,
                        vweight: parseFloat(node.getAttribute('vweight')) 
                     }
          } 
      else if(field=='smallphoto')
      { item = { src:    node.getAttribute('src') ,
                 width:  parseInt(node.getAttribute('width')) ,
                 height: parseInt(node.getAttribute('height')) ,
               } ;
        srcset = node.getAttribute('srcset').match(/\S+/g) ;
        if(srcset.length>=2) 
        { slen = srcset[1].length ;
          if(srcset[1].charAt(slen-1)=='x')
          { item.srcset = srcset[0] ; 
            item.scale = srcset[1].substring(0,slen-1) ;
          }
        }
        props.smallphoto.push(item) ; 
      }
      else if(field=="list") 
      { txt = node.getAttribute('src') ;
        if(txt.substring(0,6)=='$FILE$') txt = null ; 
      }
      else if(field=="index"||field=="info") 
        txt = { href: reluri(xmlfile,node.getAttribute('href')) , 
                title:node.getAttribute('title') } ;
      else if(field=='pixpage'||field=='gallery') 
      { txt = node.textContent ;
        txt = { href:node.getAttribute('href') , title:(txt?txt:'') } ;
      }
      else txt = node.textContent ; 
      if(field=="list") txt = reluri(xmlfile,txt) ;
      if(field=='photo') props.photo = txt.match(/\S+/g) ; // normalise
      else if(field=='gallery'||field=='pixpage') 
      { if(field=='gallery'||!props.gallery) props.gallery = txt ; }
      else if(field=='stats') props.stats = parsestats(txt) ; 
      else if(field!='opt'&&field!='smallphoto') props[field] = txt ;
    }
  }
  return props ; 
}
/* -------------------------------------------------------------------------- */

function getrteprops(nodes,xmlfile)
{ var props = new routetype() , node,i,id,txt,item,type,scale ;

  for(i=0;i<nodes.length;i++) if(nodes[i].nodeType==1) // an element
  { node = nodes[i] ; 
    id = node.tagName ;
    if(id=='pt'||id=='route') continue ;
    type = node.getAttribute('type') ;
    if(id=='optimised')
    { if(type=='routemaster') props.optim = 
        { already: 1 , 
          origlen: parseInt(node.getAttribute('from')) ,
          len:     parseInt(node.getAttribute('to')) ,
          parms:   { tol: parseFloat(node.getAttribute('tol')) ,
                     maxsep: parseFloat(node.getAttribute('maxsep')) ,
                     wppenalty: parseFloat(node.getAttribute('wppenalty')) ,
                     vweight: parseFloat(node.getAttribute('vweight')) 
                   }
        } 
      continue ; 
    }
    if(!node.childNodes[0].length) continue ; 
    text = node.childNodes[0].textContent ;
    if(id=='desc'&&(type=='routemaster'||type=='utf-8')) 
    { props.desc = text ; continue ; }
    text = normalise(text) ; 
    if( id=="imgdef"||id=="photolist"||id=="index"||id=="tracklink"
     || id=="info"||id=="gallery" )
      text = reluri(xmlfile,text) ;
    if(id=='name') props.title = text ; 
    else if(id=='date') props.date = text ; 
    else if(id=='index'||id=='gallery'||(id=='info'&&type=='routemaster')) 
      props[id] = { href:text , title:normalise(node.getAttribute('title')) } ;
    else if(id=='tracklink') 
    { props.tlink = text ; 
      props.tmode = normalise(node.getAttribute('mode')) ;
    }
    else if(id=='stars'&&type=='1-5') props.stars = parseInt(text) ; 
    else if(id=='stats') props.stats = parsestats(text) ; 
    else if(id=='img'&&type=='pix') photo = photo.concat(text.match(/\S+/g)) ;
    else if(id=='img'&&(!type||type=='url')) 
    { text = text.match(/\S+/g) ; 
      item = { src: reluri(xmlfile,text[0]) ,
               width:  parseInt(node.getAttribute('width')) ,
               height: parseInt(node.getAttribute('height')) ,
               stars:  normalise(node.getAttribute('stars'))=='*'?'*':null ,
               srcset: null , scale:null 
             } ;
      if(text.length>1&&(scale=normalise(node.getAttribute('scale')))) 
      { item.srcset = reluri(xmlfile,text[1]) ; item.scale = scale ; }
      props.smallphoto.push(item) ; 
    }
    else if((id=="imgdef"||id=='photolist')&&type=='pix') props.list = text ; 
    else if(id=='srcid'&&type=='utf-8') props.srcid = text ; 
  }
  return props ; 
}
/* -------------------------------------------------------------------------- */

function setsegpos(x) { for(var i=0;i<x.length;i++) x[i].setpos() ; }

function normalise(txt)
{ if(!txt) return txt ; 
  var s = txt.replace(/\s/g,' ') , i , j , str , c ;
  for(i=0;i<s.length&&s.charAt(i)==' ';i++) ;
  for(j=s.length;j>=i&&s.charAt(j-1)==' ';j--) ; 
  s = s.substring(i,j) ; 
  return s ; 
}
/* -------------------------------------------------------------------------- */

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

  // 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 = normalise(nodes[j].textContent) ;

  // maybe the tracks hang from courses, maybe from laps
  course = xmldoc.getElementsByTagName('Track') ;
  if(course.len==0) { alert(L.notracks) ; 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 routetype() ; }

  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) ; 
    if(props[i].indexmode) loadtype = props[i].indexmode ;
    else if(!loadtype&&props[i].stats) loadtype = 1 ; 

    // get title and trackpoints
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='Name')
    { props[i].title = normalise(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'&&(r=getpt(node,tags)))  
          segment[i].push(r.pt) ; 
      }
      segment[i] = squash(segment[i]) ; // squeeze out coincident points
      setsegpos(segment[i]) ;           // put markers on the map
    }

    // get coursepoints
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='CoursePoint')
        if((r=getpt(course[i].childNodes[j],tags)))
          addlabel(segment[i],r.pt.pos,r.label,r.caption) ; 
  }

  for(i=0;i<segment.length;i++) props[i].pts = segment[i] ; 
  if(loadtype==0) { props[0].level = 0 ; return props[0] ; }
  alert("tcx indexes are no longer supported by routemaster – use .rte instead") ; 
  return null ; 
}
/* -------------------------------------------------------------------------- */

function readgpx(xmldoc,xmlfile)
{ var xmlcoords,i,r,rp=new routetype() , tags = new gettags('gpx') , pts ;

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

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

  for(pts=rp.pts,i=0;i<xmlcoords.length;i++)
    if((r=getpt(xmlcoords[i],tags))) pts.push(r.pt) ; 
  pts = squash(pts) ; // squeeze out coincident points
  setsegpos(pts) ;    // put markers on the map

  // loop over the waypoints to get the labels
  xmlcoords = xmldoc.getElementsByTagName('wpt') ;
  for(i=0;i<xmlcoords.length;i++)
    if((r=getpt(xmlcoords[i],tags))) addlabel(pts,r.pt.pos,r.label,r.caption) ; 

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

  // get the route description
  if(!rp.desc)
  { xmlcoords = xmldoc.getElementsByTagName('desc') ;
    if(xmlcoords.length>0&&xmlcoords[0].childNodes.length>0) 
      rp.desc = normalise(xmlcoords[0].childNodes[0].textContent) ;
  }
  return rp ;
}
/* -------------------------------------------------------------------------- */

function readrte(node,xmlfile)
{ var coords,i,route,t,r,prevt,props,type ;

  coords = node.childNodes ;
  type = normalise(node.getAttribute('type')) ; 
  
  route = getrteprops(coords,xmlfile) ; 
  if(type=='metaindex') route.level = 2 ; 
  else if(type=='index') route.level = 1 ; 
  else route.level = 0 ; 

  if(route.level==0) 
  { for(prevt=i=0;i<coords.length;i++) if(coords[i].nodeType==1) 
      if(coords[i].tagName=='pt') if(r=getpt(coords[i]))
    { t = r.pt.t ; 
      if(t==null&&prevt&&r.dt) r.pt.t = new Date(prevt+r.dt*1000) ; 
      if(r.pt.t==null) prevt = null ; else prevt = r.pt.t.getTime() ; 
      route.pts.push(r.pt) ; 
    }
    route.pts = squash(route.pts) ; // squeeze out coincident points
    setsegpos(route.pts) ;          // put markers on the map
  }
  else for(i=0;i<coords.length;i++) if(coords[i].nodeType==1) 
    if(coords[i].tagName=='route') route.pts.push(readrte(coords[i],xmlfile)) ; 
  return route ; 
}
/* -------------------------------------------------------------------------- */

function readkml(xmldoc,xmlfile)
{ var xmlcoords,i,lat,lon,h,p=new routetype() ; 

  // loop over the track points to get the coords
  xmlcoords = xmldoc.getElementsByTagName('coordinates') ;
  if(xmlcoords.length==0) return { routeprops:p , props:[] } ; 
  
  xmlcoords = xmlcoords[0].textContent.split(/[,\s]+/).filter(Boolean) ; 
  p.pts = new Array(xmlcoords.length/3) ; 
  for(i=0;i<xmlcoords.length/3;i++) 
  { lat = parseFloat(xmlcoords[3*i+1]) ; 
    lon = parseFloat(xmlcoords[3*i]) ; 
    h = parseFloat(xmlcoords[3*i+2]) ; 
    if(!h) h = null ; 
    p.pts[i] = new pttype(new google.maps.LatLng(lat,lon),h) ; 
  }
  return p ;
}
/* -------------------------------------------------------------------------- */

function getlatlong(node,latstr,lonstr)
{ var lat=null,lon=null,nodes=node.childNodes,nodeno ;

  for(nodeno=0;nodeno<nodes.length;nodeno++)
  { node = nodes[nodeno] ;
    if(node.nodeName==latstr) lat = xmlfloat(node) ; 
    else if(node.nodeName==lonstr) lon = xmlfloat(node) ; 
  }
  if(lat==null||lon==null) return null ; 
  else return new google.maps.LatLng(lat,lon) ;
}
function getllpt(node)
{ return new pttype(getlatlong(node,'lat','lng'),null,null) ; }

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

function readgoogle(xmldoc)
{ var xmlcoords,i,j,k,r,node,end,poly,manoeuvre, props = new routetype() ;
  var pts = props.pts ; 

  // loop over the steps
  xmlcoords = xmldoc.getElementsByTagName('step') ;
  for(i=0;i<xmlcoords.length;i++) 
  { for(manoeuvre=poly=null,j=0;j<xmlcoords[i].childNodes.length;j++)
    { node = xmlcoords[i].childNodes[j] ;
      if(i==0&&node.nodeName=='start_location') pts.push(getllpt(node)) ; 
      else if(node.nodeName=='end_location') end = getllpt(node) ; 
      else if(node.nodeName=='maneuver') manoeuvre = node.textContent ;
      else if(node.nodeName=='polyline') 
      { for(poly=null,k=0;(!poly)&&k<node.childNodes.length;k++)
          if(node.childNodes[k].nodeName=='points') 
        { poly = node.childNodes[k].textContent ;
          poly = google.maps.geometry.encoding.decodePath(poly) ; 
        }
      }
    }
    if(manoeuvre) 
      for(j=0;j<oeuvres.length;j++) if(oeuvres[j][0]==manoeuvre) 
    { pts[pts.length-1].setlabel(oeuvres[j][1],oeuvres[j][2]) ; break ; }

    if(poly) for(k=0;k<poly.length;k++) 
      pts.push(new pttype(poly[k],null,null)) ;
    pts.push(end) ; 
  }

  pts = squash(pts) ; 
  setsegpos(pts) ;     // put markers on the map
  xmlcoords = xmldoc.getElementsByTagName('summary') ;
  if(xmlcoords.length) 
    props.title = normalise(xmlcoords[0].textContent.substring(0,15)) ;

  props.desc = L.googledirs ;
  xmlcoords = xmldoc.getElementsByTagName('start_address') ;
  if(xmlcoords.length) 
    props.desc += ' from ' + normalise(xmlcoords[0].textContent) ;
  xmlcoords = xmldoc.getElementsByTagName('end_address') ;
  if(xmlcoords.length) 
    props.desc += ' to ' + normalise(xmlcoords[0].textContent) ;
  xmlcoords = xmldoc.getElementsByTagName('copyrights') ;
  if(xmlcoords.length) 
    props.desc += ' (' + normalise(xmlcoords[0].textContent) + ')' ;
  return props ;
}
/* -------------------------------------------------------------------------- */

// https://stackoverflow.com/questions/17374893/how-to-extract-floating-numbers-from-strings-in-javascript

function readcsv(str) 
{ var i,n,p=new routetype() , regex = /[+-]?\d+(\.\d+)?/g ;
  var x = str.match(regex).map(function(v) { return parseFloat(v) ; }) ;
  n = Math.floor(x.length/2) ; 
  p.pts = new Array(n) ; 
  for(i=0;i<n;i++)
    p.pts[i] = new pttype(new google.maps.LatLng(x[2*i],x[2*i+1])) ; 
  return p ;
}
/* -------------------------------------------------------------------------- */

function readfit(rawdata)
{ var input = new Uint8Array(rawdata) , p = new routetype() ; 
  var ind,flag,tag,i,defn,lat,lon,pos,alt,time,item,nitem,prev,bigend,gmsgnum ;
  if(input.length==0) return { routeprops:p , props: [] , segments: [] } ;
  else if(input.length<12) abend(inject(L.shortfit,input.length)) ;
  tag = String.fromCharCode(input[8]) + String.fromCharCode(input[9]) + 
        String.fromCharCode(input[10]) + String.fromCharCode(input[11]) ;
  if(tag!=".FIT") abend(inject(L.unfitfit,tag)) ; // error
  var utf8decoder = new TextDecoder() , defns = new Array(16) ; 
  var lmsgnum,meaning,label,d,pts,caption,hdrlen,datalen,dt,idash ;

  function maketext(x,ind,n)
  { var text = x.slice(ind,ind+n) , k = text.indexOf(0) ; 
    if(k>=0) text = text.slice(0,k) ; 
    return utf8decoder.decode(text) ;
  }

  hdrlen = input[0] ; 
  datalen = readfitvalue(input,4,{format:6,bigend:0}) ; 
  if(input.length<hdrlen+datalen) abend(L.shortfit) ;

  for(prev=0,pts=p.pts,ind=hdrlen;ind<hdrlen+datalen;) 
  { flag = input[ind++] ; 

    if(64&flag)     // definition message
    { if(flag&32) { alert(L.devdata) ; return null ; } 
      lmsgnum = flag & 15 ; 
      bigend = input[ind+1] ;
      if(bigend) gmsgnum = (input[ind+2]<<8) | input[ind+3] ; 
      else gmsgnum = input[ind+2] | (input[ind+3]<<8) ; 
      nitem = input[ind+4] ;
      defn = { gmsgnum:gmsgnum , lmsgnum:lmsgnum , fields:[] , size:0 } ; 
      for(ind+=5,i=0;i<nitem;i++,ind+=3)
      { item = { meaning:input[ind] , size:input[ind+1] , 
                 format:input[ind+2] , bigend:bigend } ;
        defn.fields.push(item) ; 
        defn.size += item.size ; 
      }
      defns[lmsgnum] = { defn:defn , gmsgnum:gmsgnum } ; 
    }
    else           // pts message
    { if(128&flag) // compressed timestamp
      { time = (prev&~31) | (flag&31) ; 
        if(time<prev) time += 32 ; 
        lmsgnum = 3 & (flag>>5) ; 
      }
      else { time = null ; lmsgnum = 15 & flag ; }
      if(!defns[lmsgnum]) abend(L.missfit) ; // error

      gmsgnum = defns[lmsgnum].gmsgnum ;
      defn = defns[lmsgnum].defn ;

      if(gmsgnum==31) for(i=0;i<defn.fields.length;i++,ind+=item.size) // course
      { item = defn.fields[i] ; 
        if(item.meaning==5) p.title = maketext(input,ind,item.size) ; 
      }
      else if(gmsgnum==20||gmsgnum==32) // record or coursepoint
      { lat = lon = alt = label = caption = pos = null ; 
        for(i=0;i<defn.fields.length;i++,ind+=item.size)
        { item = defn.fields[i] ; 
          meaning = -1 ; 
          if(gmsgnum==20)
          { if(item.meaning<3||item.meaning==253) meaning = item.meaning ; }
          else if(item.meaning==1) meaning = 253 ;
          else if(item.meaning==2||item.meaning==3) meaning = item.meaning - 2 ; 
          else if(item.meaning==5||item.meaning==6) meaning = item.meaning ;

          if(meaning==0) lat = readfitangle(input,ind,item) ;
          else if(meaning==1) lon = readfitangle(input,ind,item) ;
          else if(meaning==2) alt = readfitvalue(input,ind,item)/5-500 ;
          else if(meaning==5) 
          { val = readfitvalue(input,ind,item) ;
            j = icons.fitnames.indexOf(val) ; 
            label = icons.names[j<0?0:j] ;
          }
          else if(meaning==6) caption = maketext(input,ind,item.size) ;
          else if(meaning==253) 
          { prev = readfitvalue(input,ind,item) ;
            time = 1000 * (prev+631065600) ;
          }
        }
        if(time==null) alert(gmsgnum==20?L.untimedrecord:L.untimedcoursept) ;
        if(lat!=null&&lon!=null) pos = new google.maps.LatLng(lat,lon) ;
        if(label||(gmsgnum==20&&pos))
        { d = new pttype(pos,alt,new Date(time)) ; 
          if(label) d.setlabel(label,caption) ; 
          pts.push(d) ; 
        }
      } 
      else ind += defn.size ; 
    }
  }

  // put the course points in position
  pts.sort(function(a,b){return a.t.getTime()-b.t.getTime();}) ;
  for(i=0;i<pts.length;i++) if(pts[i].label)
  { idash = -1 ; 
    if(i&&!pts[i-1].label) 
    { idash = i-1 ; dt = pts[i].t.getTime() - pts[i-1].t.getTime() ; }
    if(i<pts.length-1&&!pts[i+1].label)
      if(idash<0||pts[i+1].t.getTime()-pts[i].t.getTime()<dt) idash = i+1 ; 
    if(idash<0&&!pos) alert(L.cantplace+pts[i].label+
                            pts[i].caption?('('+pts[i].caption+')'):'') ; 
    if(!pts[i].pos||dist(pts[i].pos,pts[idash].pos)<5)
      pts[i].pos = pts[idash].pos ; 
  }
     
  // merge coincident points
  pts = squash(pts) ; 
  setsegpos(pts) ;     // put markers on the map
  return p ; 
}
/* -------------------------------------------------------------------------- */

function readfitvalue(input,ind,defitem)
{ var r=null,format=defitem.format&31,bigend=defitem.bigend ;
  if(format<3) 
  { r = input[ind] ; 
    if(format==1) { if(r==0x7f) r = null ; } else if(r==0xff) r = null ; 
  }
  else if(format==3||format==4) 
  { if(bigend) r = (input[ind]<<8) | input[ind+1] ; 
    else r = input[ind] | (input[ind+1]<<8) ; 
    if(format==3) { if(r==0x7fff) r = null ; } else if(r==0xffff) r = null ; 
  }
  else if(format==5||format==6) 
  { if(bigend) r = (input[ind]<<24)  | (input[ind+1]<<16) |
                   (input[ind+2]<<8) | input[ind+3] ; 
    else r = input[ind] | (input[ind+1]<<8) | 
            (input[ind+2]<<16) | (input[ind+3]<<24) ; 
    if(format==5) { if(r==0x7fffffff) r = null ; } 
    else if(r==0xffffffff) r = null ; 
  }
  if(r!=null&&(format&1)==0) r = r >>> 0 ; // type-convert to unsigned
  return r ; 
}
function readfitangle(input,ind,defitem)
{ var r = readfitvalue(input,ind,defitem) ;
  if(r==null) return null ; else return r * 90.0 / (1<<30) ; 
}
/* -------------------------------------------------------------------------- */

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

/* -------------------------------- gettags  -------------------------------- */

function gettags(mode)
{ var field ; 
  this.info =  'Info' ;
  this.time =  'Time' ;
  this.extns = 'Extensions' ; 
  this.title = 'Caption' ; 
  this.opt =   'Optimised' ; 
  this.stars = 'Stars' ; 
  this.stats = 'Stats' ; 
  this.date =  'Date' ; 
  this.desc =  'Description' ; 
  this.list =  'PhotoList' ; 
  this.photo = 'Photo' ; 
  this.pixpage = 'PixPage' ; 
  this.gallery = 'Gallery' ; 
  this.index = 'Index' ; 
  this.srcid = 'SourceId' ; 
  this.vtime = 'ValidTime' ; 
  this.vel =   'ValidAlt' ; 
  this.no =    'No' ; 
  this.name =  'Name' ;
  this.caption = 'Name' ;
  this.truelabel = 'TrueLabel' ; 
  if(mode=='tcx') { this.el = 'AltitudeMeters' ; this.label = 'PointType' ; }
  else 
  { for(field in this) this[field] = this[field].toLowerCase() ;
    if(mode=='gpx') { this.el = 'ele' ; this.label = 'type' ; }
  }
  this.mode = mode ; 
}
/* -------------------------------------------------------------------------- */

function writegps(props,xpts,mode,precision) 
{ var i,j,k,h,xmldoc,course,lap,filename,track,time,routelen,nnull,sum ; 
  var hspeed=3600.0/10,vspeed=3600/0.4,maxspeed=3600.0/50 ; // 10km/hr, 400m/hr
  var trackpoint,coursepoint,str,exstr,flag,tlast,di,dk,date,blacklist=[],pos ;
  var ndel,ano,photo,thisuri,sep,tdist,ttime,time,otime,x,y,pointdist,label ; 
  var ipts = squash(xpts) , clen = ipts.length , msecs = new Array(clen) ;
  var distance = new Array(clen) , valid = new Array(clen) , gallery ;
  var tags , title = props.title , alt = new Array(clen) ;
  if(!title) title = L.untitled ;
  else 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]=ipts[i].h)==null) nnull += 1 ; 
    time = ipts[i].t ;
    if(time) 
    { if(!date) 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 = ipts[i-1].delta ;
      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&&ipts[i].h!=null;i++) ;          // advance to null
    if(i==clen) break ; 
    for(j=i+1;j<clen&&ipts[j]==null;j++) ;       // advance to non-null
    if(i==0) { for(y=ipts[j].h;i<j;i++) alt[i] = y ; continue ; } 
    if(j==clen) { for(x=ipts[i-1].h;i<j;i++) alt[i] = x ; continue ; } 
    for(sum=k=0;k<=j-i;k++) sum = pointdist[k] = sum +ipts[i+k-1].delta ; 
    for(x=ipts[i-1].h,y=ipts[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(msecs[0]=0,hspeed=3600.0/10,vspeed=3600/0.4,i=1;i<clen;i++) 
  { h = ipts[i-1].delta*hspeed + (alt[i]-alt[i-1])*vspeed ;
    if(h<ipts[i-1].delta*maxspeed) h = ipts[i-1].delta*maxspeed ;
    msecs[i] = msecs[i-1] + h ; 
  }
    else for(date=null,i=0;i<clen;i=k)
  { for(;i<clen&&ipts[i].t!=null;i++) ;      // advance to null
    if(i==clen) break ;
    for(k=i+1;k<clen&&ipts[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) ;
  }

  if(mode=='fit') return writefit(props.title,ipts,msecs,alt) ; 
  else if(mode=='rte') return writerte(props,ipts,msecs,precision,0) ; 
  tags = new gettags(mode) ;

  // header
  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' ;
  str += 
     '<!-- https://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.name,title,'  ') + '  <Lap>\n' + 
           gentag('DistanceMeters',routelen.toFixed(0),'    ') +
           gentag('TotalTimeSeconds',time.toFixed(0),'    ') +
           addp('Begin',[ipts[0].pos.lat().toFixed(5),
                         ipts[0].pos.lng().toFixed(5)],'    ') + 
           addp('End'  ,[ipts[clen-1].pos.lat().toFixed(5),
                         ipts[clen-1].pos.lng().toFixed(5)],'    ') + 
           gentag('Intensity','Active','    ') + '  </Lap>\n  <Track>\n' ; 
  else if(mode=='gpx') str += '<metadata><name>' + title + '</name>' + 
          '</metadata>\n<trk><name>' + title + '</name>\n<trkseg>\n' ;

  // blacklist previously used coursepoints
  for(i=0;i<ipts.length;i++) if(ipts[i].label)
    blacklist.push(protectpos(ipts[i].pos,blacklist,i,precision)) ;

  // loop over trackpoints
  for(i=0;i<ipts.length;i++) 
  { pos = null ; 
    if(ipts[i].label)
      for(j=0;pos==null&&j<blacklist.length;j++) if(blacklist[j][2]==i) 
        pos = blacklist[j] ;
    if(pos==null) pos = protectpos(ipts[i].pos,blacklist,null,precision) ;
    if(mode=='tcx') str += '  <Trackpoint>\n' + addp('',pos,'    ') + 
                      gentag('DistanceMeters',distance[i].toFixed(0),'    ') ; 
    else str += '  <trkpt ' + gpxp(pos) + '>\n' ; 
    if(ipts[i].h==null) h = alt[i] ; else h = ipts[i].h ; 
    str += gentag(tags.el,h.toFixed(precision),'    ') +
           gentag(tags.time,new Date(msecs[i]).toISOString(),'    ') ; 
    if(ipts[i].photo.length>0||valid[i]==0||ipts[i].h==null)
    { str += '    <' + tags.extns + '>\n' ; 
      if(ipts[i].photo.length>0)
      { str += '      <' + tags.photo + '>'
        for(k=0;k<ipts[i].photo.length;k++)
        { if(k) str += ' ' ; str += ipts[i].photo[k] ; }
        str += '</' + tags.photo + '>\n' ; 
      }
      if(valid[i]==0) str += gentag(tags.vtime,'False','      ') ;
      if(ipts[i].h==null) str += gentag(tags.vel,'False','      ') ;
      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 = '' ; 
  gallery = getgallery() ; 
  if(props.optim) exstr += genoptim(tags.opt,props.optim,'    ') ; 
  if(props.list)  exstr += gentagattr(tags.list,'src',props.list,'    ') ; 
  if(props.stars) exstr += gentag(tags.stars,props.stars,'    ') ;
  if(props.desc)  exstr += gendesc(tags.desc,props.desc,null,'    ') ; 
  if(props.date)  exstr += gentag(tags.date,props.date,'    ') ; 
  if(props.info)  exstr += gentagattr(tags.info,'href',props.info.href,'    ') ; 
  if(props.index) 
    exstr += gentagattr(tags.index,'href',props.index.href,'    ') ; 
  if(props.srcid&&props.srcid!=props.title) 
    exstr += gentag(tags.srcid,props.srcid,'    ') ; 
  if(gallery) exstr += '    ' + genpixpage(tags.gallery,gallery) + '\n' ;
  if(exstr) 
    str += '  <' + tags.extns +'>\n' + exstr + '  </' + tags.extns + '>\n' ;

  // finally loop over coursepoints
  for(j=i=0;i<ipts.length;i++) if(ipts[i].label)
  { label = ipts[i].label ;
    k = icons.names.indexOf(label) ; 
    if(k<0) { alert('‘'+label+'’ unrecognised') ; continue ; } // can't happen
    if(mode=='tcx') str += '  <CoursePoint>\n' + addp('',blacklist[j],'    ') ;
    else str += '  <wpt ' + gpxp(blacklist[j]) + '>\n' ; 
    j += 1 ; 
    if(ipts[i].h==null) h = alt[i] ; else h = ipts[i].h ; 
    if(mode=='gpx') str += gentag(tags.label,label,'    ') ;
    else str += gentag(tags.label,icons.tcxnames[k],'    ') ;
    if(ipts[i].marker.title)
      str += gentag(tags.caption,ipts[i].marker.title,'    ') ;
    str += gentag(tags.el,h.toFixed(0),'    ') +
           gentag(tags.time,new Date(msecs[i]).toISOString(),'    ') ;
    if(mode=='tcx') 
    { if(icons.tcxnames[k]!=label) str += '    <' + tags.extns + 
               '><TrueType>' + label + '</TrueType></' + tags.extns + '>\n' ; 
      str += '  </CoursePoint>\n' ;
    }
    else str += '  </wpt>\n' ;
  }

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

function gentag(tag,val,sp) 
{ return sp + '<' + tag + '>' + xmlify(val) + '</' + tag + '>\n' ; }
function gentagattr(tag,attr,val,sp)
{ return sp + '<' + tag + ' ' + attr + '=\"' + xmlify(val) + '"/>\n' ; }

function gensrc(tag,val) { return '<' + tag + ' src="' + val + '"/>' ; }
function gentypedtag(tag,val,attr,aval,sp) 
{ if(!attr) attr = "type" ; 
  if(!aval) aval = "routemaster" ; 
  val = xmlify(val) ; 
  aval = xmlify(aval) ; 
  var len = 2*tag.length + aval.length + val.length + sp.length + attr.length ;
  var str = sp + '<' + tag + ' ' + attr + '="' + aval + '">'
  if(len<70) return str + val + '</' + tag + '>\n' ;
  else return str + '\n  ' + sp + val + '\n' + sp + '</' + tag + '>\n' ;
}
function geninfotag(info,sp) 
{ var len = 30 + sp.length , title , str = sp + '<info type="routemaster"' ; 
  if(info.title) 
  { title = xmlify(info.title)  
    str += ' title="' + title + '"' ; 
    len += 10 + title.length ;
  }
  if(len<70) return str + '>' + info.href + '</info>\n' ;
  else return str + '>\n  ' + sp + info.href + '\n' + sp + '</info>\n' ;
}
function genimgtag(img,sp)
{ var str = sp + '<img width="' + img.width + '" height="' + img.height + '"' ;
  if(img.stars) str += ' stars="' + img.stars + '"' ;
  if(img.scale) str += ' scale="' + img.scale + '"' ;
  str += '>\n  ' + sp + img.src + '\n' ; 
  if(img.srcset) str += '  ' + sp + img.srcset + '\n' ;
  return str + sp + '</img>\n' ;
}
function genurl(tag,val,attr,aval,sp) 
{ var len,ltag=tag ;
  if(attr&&aval) ltag += ' ' + attr + '="' + aval + '"' ; 
  len = tag.length + ltag.length + val.length + sp.length ;
  if(len<70) return sp + '<' + ltag + '>' + xmlify(val) + '</' + tag + '>\n' ; 
  else return sp + '<' + ltag + '>\n  ' + sp +  xmlify(val) + '\n' + 
              sp + '</' + tag + '>\n' ; 
}
function genindex(val,sp) 
{ var str , len = 10+val.href.length+sp.length ;
  if(val.title) len += 10 + val.title.length ; 
  str = sp + '<index' ; 
  if(val.title) str += ' title="' + xmlify(val.title) + '"' ; 
  if(len<70) return str + '>' + xmlify(val.href) + '</index>\n' ; 
  else return str + '>\n  ' + sp +  xmlify(val.href) + '\n' + sp + '</index>\n';
}
function addp(pfx,pos,sp)
{ return sp + '<' + pfx + 'Position>\n' +
               gentag('LatitudeDegrees', pos[0],sp+'  ') +
               gentag('LongitudeDegrees',pos[1],sp+'  ') + 
         sp + '</' + pfx + 'Position>\n' ;
}
function addpos(pfx,pos) 
{ return addp(pfx,[pos.lat().toFixed(5),pos.lng().toFixed(5)]) ; }

function genoptim(tag,optim,sp)
{ var str = sp + '<' + tag + ' type="routemaster" from="' + optim.origlen + 
            '" to="' + optim.len + '"';
  if(optim.parms) 
  { str += '\n             ' ;
    if(optim.parms.tol) str += 'tol="' + optim.parms.tol.toFixed(0) + '"' ;
    if(optim.parms.maxsep) 
      str += ' maxsep="' + optim.parms.maxsep.toFixed(0) + '"' ;
    if(optim.parms.wppenalty) 
      str += ' wppenalty="' + optim.parms.wppenalty.toFixed(0) + '"' ;
    if(optim.parms.vweight)
      str += ' vweight="' + optim.parms.vweight.toFixed(1) + '"' ;
  }
  return str + '/>\n' ;
}
function gpxp(pos) { return 'lat="' + pos[0] + '" lon="' + pos[1] + '"' ; }

function protectpos(pos,list,ind,precision)
{ var rmod = [ pos.lat() , pos.lng() ] ;
  var r = [ rmod[0].toFixed(5+precision) , 
            rmod[1].toFixed(5+precision) , 
            ind ] ;
  var i , j , clash , eps=0 ; 
  for(clash=1,i=0;clash;i++) 
  { for(clash=j=0;clash==0&&j<list.length;j++)
    { if(list[j][0]==r[0]&&list[j][1]==r[1]) clash = 1 ; }
    if(clash) 
    { if(eps==0) eps = Math.pow(0.1,precision+5) ; 
      rmod[i&1] += eps ; 
      r[i&1] = rmod[i&1].toFixed(5+precision) ; 
    }
  }
  return r ; 
}
/* -------------------------------------------------------------------------- */

function writerteprops(props,nesting,gallery)
{ var i , str = '' , sp = '  ' ; // top-level routes have nested props
  for(i=0;i<nesting;i++) sp += '  ' ;
  if(props.title) str += gentag('name',props.title,sp) ;  

  // fields which belong to routes but not to indexes
  if(props.level==0&&nesting==1) // dates are properties of routes in indexes
    if(props.date) str += gentag('date',props.date,sp) ; 
  if(props.level==0&&nesting<2) // desc is a prop of a route alone or in index
    if(props.desc) str += gendesc('desc',props.desc,"routemaster",sp) ; 
  // stars are props of routes, either alone or in indexes, or indexes in metas
  if(props.level<2&&nesting<2) if(props.stars) 
    str += gentypedtag('stars',props.stars.toString(),0,"1-5",sp) ;

  // galleries and index links occur only at top level
  if(nesting==0) 
  { if(props.index) str += genindex(props.index,sp) ;
    if(gallery&&gallery!='/dev/null') 
      str += gentypedtag('gallery',gallery.href,'title',gallery.title,sp) ;
  }

  // small photos, stats and tlinks only occur at depth 1
  if(nesting==1)
  { if(props.stats) 
      str += gentypedtag('stats',formatstats(props.stats),0,0,sp) ;
    if(props.smallphoto&&props.smallphoto.length) 
      for(i=0;i<props.smallphoto.length;i++)
        str += genimgtag(props.smallphoto[i],sp) ;
    if(props.tlink) 
      str += genurl('tracklink',props.tlink,'mode',props.tmode,sp) ; 
  }

  // the following properties only occur in bare routes 
  if(nesting==0&&props.level==0) 
  { if(props.optim) str += genoptim('optimised',props.optim,sp) ; 
    if(props.list) str += gentypedtag('imgdef',props.list,0,'pix',sp) ; 
    if(props.info) str += geninfotag(props.info,sp) ; 
    if(props.srcid&&props.srcid!=props.title) 
      str += gentypedtag('srcid',props.srcid,'type','utf-8',sp) ; 
  }

  return str ;
}
/* -------------------------------------------------------------------------- */

function writerte(props,ipts,msecs,precision,nesting) 
{ var i,j,dt,len,tlast,str='',gal=null , blx = '' ; 
  for(i=0;i<nesting;i++) blx += '  ' ;

  // header
  if(blx=='') 
  { str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
     '<!-- https://www.masterlyinactivity.com/software/routemaster.html -->\n' ;
    gal = getgallery() ; 
  }

  // props
  str += blx + '<route>\n' + writerteprops(props,nesting,gal) ;

  // route points
  for(tlast=-1,i=0;i<ipts.length;i++) 
  { str += blx + '  <pt ' + gpxp([ipts[i].pos.lat().toFixed(5+precision),
                                  ipts[i].pos.lng().toFixed(5+precision)]) ; 
    if(nesting==0)
    { if(ipts[i].h) str += ' h=\"' + ipts[i].h.toFixed(precision) + '\"' ; 
      if(ipts[i].t)
      { if(tlast>=0&&i<tlast+10)
        { dt = ((msecs[i]-msecs[i-1])/1000).toFixed(3) ;
          for(j=dt.length;j>0&&dt.charAt(j-1)=='0';j--) ; 
          if(j>0&&dt.charAt(j-1)=='.') j-- ; 
          if(j<dt.length) dt = dt.slice(0,j) ; 
          str += ' dt=\"' + dt + '\"' ;
        }
        else 
        { str += ' t=\"' + new Date(msecs[i]).toISOString() + '\"' ; 
          tlast = i ; 
        }
      }
      else tlast = -1 ;
      if(ipts[i].label) str += ' label=\"' + ipts[i].label + '\"' ; 
      if(ipts[i].caption) str += ' caption=\"' + ipts[i].caption + '\"' ; 
      if(ipts[i].photo.length)
      { str += '>\n' ;
        for(j=0;j<ipts[i].photo.length;j++) 
          str += gentypedtag('img',ipts[i].photo[j],'type','pix',blx+'    ') ;
        str += blx + '  </pt>\n' ;
      }
      else str += '/>\n' ; 
    }
    else str += '/>\n' ; 
  }
  return str + blx + '</route>\n' ;
}
/* -------------------------------------------------------------------------- */

function writefit(title,ipts,msecs,alt) 
{ var clen=ipts.length,i,j,k,ncp,n,offs,dist ;
  for(ncp=i=0;i<clen;i++) if(ipts[i].label||ipts[i].label==0) ncp += 1 ; 
  var utf8encoder = new TextEncoder() ; 
  var name = utf8encoder.encode(title) , tlen = name.length ; 
  var dlen = 19*clen + 26*ncp ; // 19 bytes for a record + 26 for a coursepoint
  var fit = new Uint8Array(175+tlen+dlen) ; 
  var t0 = new Date() , seconds = Math.floor(t0.getTime()/1000) - 631065600 ; 

  // check msecs
  offs = msecs[0] - 1000 * 631065600 ;
  if(offs<0) for(i=0;i<clen;i++) msecs[i] -= offs ; 
  for(i=0;i<clen;i++) msecs[i] = Math.floor(msecs[i]/1000) - 631065600 ; 

  /* ----------------------------- file_id ---------------------------------- */
              // hdr:0:big:gma:gmb:nfields
  fitinject(fit,14,   0x40,0,0,0,0,7) ;  // definition header 
              // meaning:size:format
  fitinject(fit,20,0,1,0) ;              // format of file type 
  fitinject(fit,23,1,2,0x84) ;           // format of manufacturer 
  fitinject(fit,26,2,2,0x84) ;           // format of product 
  fitinject(fit,29,4,4,0x86) ;           // format of serialno 
  fitinject(fit,32,3,4,0x8c) ;           // format of creation time 
  fitinject(fit,35,5,2,0x84) ;           // format of number 
  fitinject(fit,38,8,16,7) ;             // format of product name 

  fitinject(fit,41, 0, 6, 0,0, 0,0) ;    // lmsgnum=0, course=6, manuf, product
  fitinject4(fit,47,seconds) ;           // use time as serialno
  fitinject4(fit,51,seconds) ;           // time
  fitinject2(fit,55,1) ;                 // number '1'
  fitinject(fit,57,0x72,0x6F,0x75,0x74,0x65) ;      // 'route'
  fitinject(fit,62,0x6D,0x61,0x73,0x74,0x65,0x72) ; // 'master'
  fitinject(fit,68,0x2E,0x61,0x70,0x70,0) ;         //'.app'
  n = 73 ;

  /* ------------------------------ course ---------------------------------- */
                  // hdr:0:big:gma:gmb:nfields
  fitinject(fit,n,   0x41,0,0,31,0,1) ;   // definition header 
  fitinject(fit,n+6,5,tlen+1,7) ;         // format of course name 
  fitinject(fit,n+9,1) ; 
  for(n+=10,i=0;i<tlen;i++,n++) fitinject(fit,n,name[i]) ; 
  fitinject(fit,n,0) ;                    // null terminated
  n = 84 + tlen ; 

  /* -------------------------------- lap ----------------------------------- */
                  // hdr:0:big:gma:gmb:nfields
  fitinject(fit,n,   0x42,0,0,19,0,2) ;   // definition header 
  fitinject(fit,n+6,2,4,0x86) ;           // format of start time 
  fitinject(fit,n+9,253,4,0x86) ;         // format of timestamp 
  fitinject(fit,n+12,2) ;                 // local msg num
  fitinject4(fit,n+13,msecs[0]) ;         // start time 
  fitinject4(fit,n+17,msecs[0]) ;         // timestamp
  n = 105 + tlen ; 

  /* ---------------------------- event start ------------------------------- */
                  // hdr:0:big:gma:gmb:nfields
  fitinject(fit,n,   0x43,0,0,21,0,3) ;   // definition header 
  fitinject(fit,n+6,253,4,0x86) ;         // format of timestamp 
  fitinject(fit,n+9,0,1,0) ;              // format of event 
  fitinject(fit,n+12,1,1,0) ;             // format of eventtype 
  fitinject(fit,n+15,3) ;                 // local msg num
  fitinject4(fit,n+16,msecs[0]) ;         // timestamp
  fitinject(fit,n+20,0,0) ;               // two zero bytes
  n = 127 + tlen ; 

  /* --------------- definitions for records and course points -------------- */
                  // hdr:0:big:gma:gmb:nfields
  fitinject(fit,n,   0x44,0,0,20,0,5) ;   // definition header for record
  fitinject(fit,n+6,253,4,0x86) ;         // format of timestamp 
  fitinject(fit,n+9,0,4,0x85) ;           // format of lat 
  fitinject(fit,n+12,1,4,0x85) ;          // format of long 
  fitinject(fit,n+15,2,2,0x84) ;          // format of altitude 
  fitinject(fit,n+18,5,4,0x86) ;          // format of distance 
  n = 148 + tlen ; 

  fitinject(fit,n,   0x45,0,0,32,0,4) ;   // definition header for coursepoint
  fitinject(fit,n+6,1,4,0x86) ;           // format of timestamp 
  fitinject(fit,n+9,4,4,0x86) ;           // format of distance 
  fitinject(fit,n+12,5,1,0) ;             // format of coursepoint type 
  fitinject(fit,n+15,6,16,7) ;            // format of coursept name (16 bytes)
  n = 166 + tlen ; 

  /* ------------------------------ records --------------------------------- */

  for(dist=i=0;i<clen;dist+=ipts[i].delta,i++)
  { fitinject(fit,n,4) ;                  // header for record
    fitinject4(fit,n+1,msecs[i]) ;
    fitinjectangle(fit,n+5,ipts[i].pos.lat()) ; 
    fitinjectangle(fit,n+9,ipts[i].pos.lng()) ; 
    fitinject2(fit,n+13,(alt[i]+500)*5) ;
    fitinject4(fit,n+15,dist*100) ;
    n += 19 ; 
    if(!ipts[i].label) continue ; 

    fitinject(fit,n,5) ;                   // header for course point
    fitinject4(fit,n+1,msecs[i]) ;
    fitinject4(fit,n+5,dist*100) ;
    j = icons.names.indexOf(ipts[i].label) ; 
    fitinject(fit,n+9,j<0?0:icons.fitnames[j]) ; 
    if(ipts[i].caption)
    { name = utf8encoder.encode(ipts[i].caption) ; k = name.length ; }
    else k = 0 ; 
    for(j=0;j<k&&j<15;j++) fitinject(fit,n+10+j,name[j]) ; 
    for(;j<16;j++) fitinject(fit,n+10+j,0) ; 
    n += 26 ; 
  }
  n = 166 + tlen + dlen ; 

  /* ----------------------------- event end -------------------------------- */
                  // hdr:0:big:gma:gmb:nfields
  fitinject(fit,n,3) ;                    // local msg num
  fitinject4(fit,n+1,msecs[clen-1]) ;     // timestamp
  fitinject(fit,n+5,0,9) ;                // event 0; 9 means end
  n = 173 + tlen + dlen ; 

  /* ------------------------------ header ---------------------------------- */

  fitinject(fit,0,14,0x10,0x7a,0x52) ;    // protocol version number
  fitinject(fit,8,0x2e,0x46,0x49,0x54) ;  // ".FIT"
  fitinject4(fit,4,n-14) ;                // length of pts component of file
  fitinject2(fit,12,checksum(fit,0,12)) ; 
  fitinject2(fit,n,checksum(fit,14,n-14)) ; 
  return fit ; 
}

function fitinject(buf,pos,p0,p1,p2,p3,p4,p5) 
{ buf[pos] = p0 ; 
  if(p1||p1==0) buf[pos+1] = p1 ; else return ; 
  if(p2||p2==0) buf[pos+2] = p2 ; else return ; 
  if(p3||p3==0) buf[pos+3] = p3 ; else return ; 
  if(p4||p4==0) buf[pos+4] = p4 ; else return ; 
  if(p5||p5==0) buf[pos+5] = p5 ; 
}
function fitinject2(buf,pos,x) 
{ fitinject(buf,pos,x&0xff) ; fitinject(buf,pos+1,(x>>8)&0xff) ; }
function fitinject4(buf,pos,x) 
{ fitinject(buf,pos,x&0xff) ; fitinject(buf,pos+1,(x>>8)&0xff) ; 
  fitinject(buf,pos+2,(x>>16)&0xff) ; fitinject(buf,pos+3,(x>>24)&0xff) ;
}
function fitinjectangle(buf,pos,x) { fitinject4(buf,pos,x*(1<<30)/90.0) ; }

function checksum(x,start,n)
{ var crc_table =
   [ 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
     0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400 ] ;
  var i,crc,tmp ;

  for(crc=0,i=start;i<start+n;i++)
  { // compute checksum of lower four bits of byte
    tmp = crc_table[crc & 0xF] ;
    crc = (crc >> 4) & 0x0FFF ;
    crc = crc ^ tmp ^ crc_table[x[i] & 0xF] ;

    // now compute checksum of upper four bits of byte
    tmp = crc_table[crc & 0xF] ;
    crc = (crc >> 4) & 0x0FFF ;
    crc = crc ^ tmp ^ crc_table[(x[i] >> 4) & 0xF] ;
  }
  return crc ;
}
/* -------------------------------- getstats  ------------------------------- */

function getstats(route)
{ var stats = routestats([route]) ;
  route.photo = stats.pix ; 
  route.date = stats.date ;
  route.stats = [ 1 , stats.dist , stats.asc , stats.desc , 
                  stats.minalt , stats.maxalt ] ; 
} 
function getmetastats(route)
{ var i,s1,s2,n,x,s ;
  for(x=s2=s1=n=i=0;i<route.pts.length;i++)
  { s1 += route.pts[i].stats[1] ; 
    s2 += route.pts[i].stats[2] ; 
    if((s=route.pts[i].stars)) { x += s*s ; n += 1 ; }
  }
  route.stats = [ route.pts.length , s1 , s2 ] ; 
  if(n) route.stars = Math.floor(0.5+Math.sqrt(x/n)) ; else route.stars = null ;
}
function unspace(str)
{ var k,r='',c ;
  for(k=0;k<str.length;k++) 
  { c = str.charAt(k) ; 
    if(c!=' '&&c!='m'&&c!='k'&&c!='\u202f') r += str.charAt(k) ; 
  }
  return parseFloat(r) ; 
}
// route.stats = 
//   [ 1 , stats.dist , stats.asc , stats.desc , stats.minalt , stats.maxalt ] ; 

function parsestats(stats) 
{ var k = stats.indexOf('↓'),tok ;
  if(k<0) // "11 routes; 339km; ↑11 859m"
  { var r = [ null , null , null ] ; 
    k = stats.indexOf(' ') ;        //    ^
    r[0] = parseInt(stats.substring(0,k)) ;
    stats = stats.substring(k+1) ;  // "routes; 339km; ↑11 859m"
    k = stats.indexOf(' ') ;        //         ^
    stats = stats.substring(k+1) ;  // "339km; ↑11 859m"
    k = stats.indexOf(';') ;        //       ^
    r[1] = 1000 * unspace(stats.substring(0,k)) ; // "339km"
    k = stats.indexOf('↑') ; //         ^
    r[2] = unspace(stats.substring(k+1)) ; // "11 859m"
    return r ; 
  }
  else // "Distance 7.2km; altitude 1559-1766m; ↑408m ↓322m"
  { var r = [ 1 , null , null , null , null , null ] ; 
    k = stats.indexOf(' ') ;        //    ^
    stats = stats.substring(k+1) ;  // "7.2km; altitude 1559-1766m; ↑408m ↓322m"
    k = stats.indexOf(';') ;        //       ^
    if(stats.substring(k-2,k)=='km')
      r[1] = 1000 * unspace(stats.substring(0,k-2)) ; // "7.2km"
    else r[1] = unspace(stats.substring(0,k-1)) ; // "7200m"
    k = stats.indexOf('↑') ;        //         ^
    tok = stats.substring(k+1) ; 
    r[2] = unspace(tok.substring(0,tok.indexOf('m'))) ; 
    k = stats.indexOf('↓') ; //         ^
    tok = stats.substring(k+1) ; 
    r[3] = unspace(tok.substring(0,tok.indexOf('m'))) ; 
    k = stats.indexOf('-') ; //         ^
    if(k<0) k = stats.indexOf('–') ; //         ^
    tok = stats.substring(k+1) ; 
    r[5] = unspace(tok.substring(0,tok.indexOf('m'))) ; 
    stats = stats.substring(0,k) ; 
    k = stats.lastIndexOf(' ') ; //         ^
    r[4] = unspace(stats.substring(k+1)) ; 
    return r ; 
  }
}
function diststring(val)
{ var d = (val/100).toFixed(0) , n = d.length ;
  return d.substring(0,n-1) + '.' + d.charAt(n-1) ;
}
function kmstring(val) 
{ if(val>9999) return diststring(val) + 'km' ;
  else return val.toFixed(0) + 'm' ;
}
function formatstats(stats)
{ if(stats.length==3)
    return inject(L.numroutes,stats[0].toFixed(0)) + '; ' +
           (stats[1]/1000).toFixed(0) + 'km; ↑' + stats[2].toFixed(0) + 'm' ;

  return L.distance + ' ' + kmstring(stats[1]) + '; ↑' + stats[2].toFixed(0) + 
              'm ↓' + stats[3].toFixed(0) + 'm; ' + L.altitude + ' ' +
              stats[4].toFixed(0) + '-' + stats[5].toFixed(0) + 'm' ;
}
function prettystats(stats)
{ var div = domcreate('div',domcreate('b',L.stats)) , s ; 
  domadd(div,': ') ; 
  if(stats.length==3)
  { domadd(div,inject(L.numroutes,stats[0].toFixed(0))+'; ') ; 
    div.appendChild(prettynum(stats[1]/1000,0,'km; ↑')) ; 
    div.appendChild(prettynum(stats[2],stats[2]>9999,'m')) ; 
  }
  else
  { domadd(div,L.distance+' ') ; 
    if(stats[1]>1999)
    { s = domcreate('span',diststring(stats[1]),'style','padding-right:1.5px') ;
      div.appendChild(s) ;
      s = 'km; ↑' + stats[2].toFixed(0) ;
    }
    else 
    { s = domcreate('span',stats[1].toFixed(0),'style','padding-right:1.5px') ;
      div.appendChild(s) ;
      s = 'm; ↑' + stats[2].toFixed(0) ;
    }
    div.appendChild(domcreate('span',s,'style','padding-right:1.5px')) ;
    s = 'm ↓' + stats[3].toFixed(0) ;
    div.appendChild(domcreate('span',s,'style','padding-right:1.5px')) ;
    s = 'm; ' + L.altitude + ' ' + stats[4].toFixed(0) + 
                             '-' + stats[5].toFixed(0) ;
    div.appendChild(domcreate('span',s,'style','padding-right:1.5px')) ;
    domadd(div,'m') ; 
  }
  return div ; 
}
/* ------------------------------- gatherpix  ------------------------------- */

function extendphoto(photo,done,name)
{ var ind,item,k,img,scale,imgscale,src, sizes = imginfo.sizes ;

  if((ind=findimage(imginfo.sect,name)))
  { item = imginfo.sect[ind[0]].list[ind[1]] ;
    for(k=0;k<done.length&&done[k]!=ind;k++) ;
    if(k<done.length) return ;
    src = item.filename + item.thumbshape[2] + '.jpg' ;
    img = { src:src , width: item.thumbshape[0] , 
                      height:item.thumbshape[1] , 
                      stars: item.visibility=='*'?'*':null } ; 
    if(item.hithumb) 
    { img.srcset = item.filename + item.hithumb.suffix + '.jpg' ;
      img.scale = item.hithumb.scale ;
    }
    else 
    { scale = ( sizes[0].scale * item.thumbshape[0] ) / item.shape[0] ;
      for(imgscale=null,k=0;k<sizes.length;k++) 
        if((!sizes[k].type)&&(sizes[k].scale>scale))
          if(!imgscale||sizes[k].scale<imgscale)
      { img.srcset = sizes[k].suffix ; imgscale = sizes[k].scale ; }
      if(img.srcset) 
      { img.srcset = item.filename + img.srcset + '.jpg' ;
        img.scale = (imgscale/scale).toFixed(1) ;
      }
    }
    photo.push(img) ; 
    done.push(ind) ; 
  }
}
function gatherpix(route) // construct small photos from images
{ if(!imginfo.status) return [] ; 
  var photo,i,j,done ; 

  for(done=[],photo=[],i=0;i<route.pts.length;i++)
    if(route.pts[i].photo&&route.pts[i].photo.length)
      for(j=0;j<route.pts[i].photo.length;j++) 
        extendphoto(photo,done,route.pts[i].photo[j]) ;
  return photo ; 
}
function thumbpix(route) // construct small photos from images
{ if(!imginfo.status) return [] ; 
  var photo=[],j,done ; 

  if(route.photo&&route.photo.length) for(done=[],j=0;j<route.photo.length;j++) 
    extendphoto(photo,done,route.photo[j]) ;
  return photo ; 
}
/* ------------------------------- writeindex  ------------------------------ */

function writeindex(index,nesting) 
{ var i,str='',blx='' ; 
  for(i=0;i<nesting;i++) blx += '  ' ;

  if(nesting==0)
    str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
     '<!-- https://www.masterlyinactivity.com/software/routemaster.html -->\n' ;
  str += blx + '<route type="'+(index.level==1?'index':'metaindex')+'">\n' ; 
  str += writerteprops(index,nesting,index.gallery) ; 

  for(i=0;i<index.pts.length;i++)
    if(index.pts[i].level) str += writeindex(index.pts[i],nesting+1) ; 
    else str += writerte(index.pts[i],index.pts[i].pts,null,0,nesting+1) ; 

  return str + blx + '</route>\n' ; 
}
/* -------------------------------- indexify  ------------------------------- */

function indexify(route,optparms,reclev)
{ var origin,i,j , r = new routetype() , stats , n = route.pts.length ;
  r.pts = new Array(n) ; 

  // processing the top-level route - get photos and stats before optimisation
  if(!reclev) 
  { for(field in route) if(typeof(route[field])!='object') 
      r[field] = route[field] ;
    // if the loaded file has pix images, convert to smallphotos
    if(route.list&&route.level==0) 
    { if(imginfo.uri!=route.list) // never satisfied when saving a route as indx
        getlist(route.list,'tcx',r,route) ; // does a deferred gatherpix
      else r.smallphoto = gatherpix(route) ; 
    }

    if(!route.level) // reformat the stats for a route incorporated in an index 
    { stats = routestats([route]) ;
      r.date = stats.date ;
      r.stats = [ 1 , stats.dist , stats.asc , stats.desc , 
                    stats.minalt , stats.maxalt ] ; 
    } 
    
    origin = route.origin ;
    if( origin && origin[1] 
     && (origin[1].substring(0,3)=='uri'||origin[1]=='refresh') ) 
         r.tlink = origin[0] ;
    else if(origin&&origin[0]) r.tlink = '$FILE$/' + origin[0] ; 
    else r.tlink = '$FILE$' ; 
  }
  else 
  { for(i in {title:0,stats:0,stars:0,level:0}) r[i] = route[i] ;
    if(reclev==1) for(i=0;i<route.smallphoto.length;i++) 
      if(route.smallphoto[i].stars) r.smallphoto.push(route.smallphoto[i]) ; 
  }

  if(route.level) for(i=0;i<n;i++) 
    r.pts[i] = indexify(route.pts[i],optparms,reclev?reclev+1:1) ; 
  else
  { for(i=0;i<n;i++) 
    { r.pts[i] = new pttype(route.pts[i].pos) ; 
      r.pts[i].delta = route.pts[i].delta ;
    }
    for(i=0;i<optparms.length;i++) optimmerge(r,optimise(r.pts,optparms[i])) ; 
  }

  if(!reclev&&route.level) 
  { for(i=0;i<n;i++) for(j=0;j<r.pts[i].smallphoto.length;j++) 
      r.smallphoto.push(r.pts[i].smallphoto[j]) ; 
    getmetastats(r) ; 
  }
  return r ; 
}
/* -------------------------------------------------------------------------- */

function getgallery()
{ var pp = null ; 
  if(routeprops&&routeprops.gallery)
    pp = { href:routeprops.gallery.href , title:routeprops.gallery.title } ;
  if(imginfo&&imginfo.gallery) 
    pp = { href:imginfo.gallery , title:imginfo.title } ;
  return pp ;
}
function genpixpage(tag,gallery)
{ return '<' + tag + ' href="' + gallery.href + '">' + gallery.title + 
         '</' + tag + '>' ;
}
function gendesc(tag,desc,type,sp)
{ if( desc.indexOf('\n')>=0 || desc.indexOf('>')>=0 || desc.indexOf('<')>=0 ) 
    desc = '<![CDATA[' + desc + ']]>' ;
  if(!type) return sp + '<' + tag + '>' + desc + '</' + tag + '>\n' ; 
  else return sp + '<' + tag + ' type=\"' + type +'\">' + 
              desc + '</' + tag + '>\n' ; 
}
/* -------------------------------------------------------------------------- */

// the arg to routestats is an array of items each of which has a pts field

function routestats(segments)
{ var tlast=null,err=0,dd=0,nnull=0,maxsep=0,nlabels=0,des=0,asc=0,ntimes=0 ;
  var nowpts=0,s0,s1,oalt,alt,otime,time,sep,minalt=null,maxalt=null,date=null ;
  var photo=[] ;

  for(s0=0;s0<segments.length;nowpts+=segments[s0].pts.length,s0++) 
    for(oalt=null,s1=0;s1<segments[s0].pts.length;otime=time,s1++)
  { if((alt=segments[s0].pts[s1].h)==null) nnull += 1 ; 
    else
    { if(oalt!=null) 
      { if(alt>oalt) asc += alt-oalt ; else des += oalt - alt ; } 
      oalt = alt ;
      if(maxalt==null||alt>maxalt) maxalt = alt ; 
      if(minalt==null||alt<minalt) minalt = alt ; 
    }
    if(segments[s0].pts[s1].label) nlabels += 1 ;
    photo = photo.concat(segments[s0].pts[s1].photo) ;

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

    if(s1) 
    { dd += ( sep = segments[s0].pts[s1-1].delta ) ; 
      if(sep>maxsep) maxsep = sep ; 
    }
  }
  return { dist:dd     , asc:asc       , desc:des      , outoforder:err , 
           nnull:nnull , npts:nowpts   , maxsep:maxsep , nlabels:nlabels , 
           pix:photo   , ntimes:ntimes , maxalt:maxalt , minalt:minalt ,
           date:date
         }
}
/* -------------------------------------------------------------------------- */

// this function collapses almost coincident points onto a single point.
// it is invoked on input (perhaps out of the fear that exact coincidence will 
// cause the optimisation to blow up?) and again on output (perhaps from a fear
// that the user will have interpolated so many points that other applications
// get confused).
//    rewritten nov '19 (a) to avoid object.assign for safari, (b) to collapse
// more than 2 nearly coincident points. at the same time i removed the call
// to setpos (which seemed incorrect on output), writing a setsegpos()
// function to do the job on input; and I made the arg to setpos() optional.

function squash(pts)
{ var i,idash,j,k,pt, ilen=pts.length , npts = new Array(ilen) ; 

  // arithmetic precision of lat/long at the equator is 1.1m (5dp)
  for(j=i=0;i<ilen;i=idash)
  { for(idash=i+1;idash<ilen&&dist(pts[idash].pos,pts[i].pos)<1.2;idash++) ; 
    if(idash==i+1) { npts[j++] = pts[i] ; continue ; }
    pt = clone(pts[i]) ; 
    for(pt.photo=[],k=i;k<idash;k++) 
    { if(pts[k].photo.length)
      { pt.photo = pt.photo.concat(pts[k].photo) ; 
        pt.photomarker = pts[k].photomarker ;
      }
      if(pts[k].label)
      { pt.label = pts[k].label ;
        pt.caption = pts[k].caption ;
        pt.marker = pts[k].marker ;
      }
    }
    npts[j++] = pt ; 
  }
  npts.length = j ; 
  return npts ;
}
function clone(x) { var i,y={} ; for(i in x) y[i] = x[i] ; return y ; }

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

function absuri(href)
{ var s = href.substring(0,7).toLowerCase() ; 
  return s=='http://' || s == 'file://' || (s=='https:/'&&href.charAt(7)=='/') ;
}
/* -------------------------------------------------------------------------- */

var svgns = "http://www.w3.org/2000/svg" ;  

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

function underline(d) 
{ d.setAttribute('style',
      'margin-bottom:2px;border-bottom:solid 1px silver;padding-bottom:2px') ; 
  return d ; 
}
function textdiv(title,str,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 = domcreate('b',title+': ') ; 

  if(lim==null||str.length<lim)
  { nobr = domcreate('nobr',title!=null?b:null) ;
    domadd(nobr,str) ; 
    div.appendChild(nobr) ;
  }
  else 
  { if(title!=null) div.appendChild(b) ; 
    domadd(div,str) ; 
    if(flag==0) underline(div) ;  
  }
  return div ; 
}
/* -------------------------------------------------------------------------- */

function highdiv(segno,shifted) 
{ var div,scroll,p,a,d,dwid,s,ptno,nseg,word,routeno,base=segments[0] ; 
  var link , nfetched = 2 , sect = imginfo.sect , sizes = imginfo.sizes ; 
  var seg = base.pts[segno] , gallery = seg.gallery , dellink,golink,updlink ; 
  var items=[],i,j,k,ind,maxh,minw,sum,scroll,npts,image=[null,null,null] ;

  if(base.level) items = seg.smallphoto ; 
  else if(imginfo.uri&&imginfo.status=='ready')
    for(i=0;i<seg.photo.length;i++) if((ind=findimage(sect,seg.photo[i])))
  { for(j=0;j<items.length&&items[j].ind!=ind;j++) ;
    if(j==items.length)
    { s = sect[ind[0]].list[ind[1]].thumbshape ;
      items.push({ ind:ind , width:s[0] , height:s[1] , top:0 }) ;
    }
  }

  div = domcreate('div',null,'style','font-family:helvetica') ; 

  if(items.length>0)  
  { minw = items[0].width ;
    if(items.length>1) minw += items[items.length-1].width ;
    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].height>maxh) maxh = items[i].height ;
      if(i)
      { sum = items[i].width + items[i-1].width ; if(sum<minw) minw = sum ; }
    }
    for(i=0;i<items.length;i++) 
      items[i].top = Math.floor(0.5+(maxh-items[i].height)/2) ;

    scroll = document.createElement('div') ; 
    if(items.length==1) minw -= 4 ; 
    scroll.setAttribute('style','position:relative;width:'+(minw+4)+'px;'+
                                'height:'+(maxh+4)+'px;overflow:hidden;'+
                                'left:calc((100% - '+((minw+4)+'px')+')/2)') ; 
    for(sum=i=0;i<2&&i<items.length;sum+=items[i].width+4,i++)
    { image[i].addimage(sum) ; scroll.appendChild(image[i].img) ; }
    div.appendChild(scroll) ;
  }
  else if(base.level<2&&imginfo&&imginfo.status&&imginfo.status!='ready')
  { if(imginfo.status=='waiting') s = inject(L.waitingfor,L.photolist) ;
    else s = L.listnotfound ;
    p = domcreate('div',genspan(s)) ; 
    div.appendChild(underline(p)) ;
  }

  div.appendChild(titlediv('title',seg.title,0,null)) ; 
  if(seg.stars!=null) div.appendChild(starsline(seg.stars,0)) ;

  if(base.level<2)
  { d = titlediv('desc',seg.desc,null,0) ; // not linkable
    if(items.length>0&&seg.desc&&seg.desc.length>=50) 
    { if(minw+4<400) dwid = 400 ; else dwid = minw+4 ;  
      d.setAttribute('style','min-width:'+dwid+'px') ; 
    }
    div.appendChild(d) ; 
  }

  if(seg.stats!=null) div.appendChild(prettystats(seg.stats)) ; 
  if(seg.date!=null) div.appendChild(textdiv(L.date,seg.date)) ; 

  if(base.level==2&&gallery) div.appendChild(genindexlink(gallery,L.view)) ;

  dellink = updlink = golink = null ; 

  // delete route
  function delfactory(i) 
  { return function() { selected[0] = i ; discard() ; } ; } ;
  if(base.pts.length>1) 
    dellink = genclickfn(delfactory(segno),'['+caps(L.del)+']') ;

  // go to route / update route
  function updfactory(i) { return function() { refresh(i) ; } ; } ;
  if( seg.tlink && seg.tlink.substring(0,6)!='$FILE$'
   && (link=trackref(base.origin,seg.tlink,1)) )
  { if(seg.tmode) link += '&mode=' + seg.tmode ;
    golink = genlink(link,'['+L.view1+']',1) ;
    golink.setAttribute('onclick',"infowindow.close()") ; 
    updlink = genclickfn(updfactory(segno),'['+caps(L.refresh)+']') ;
  }
  
  if(dellink||golink) 
  { d = document.createElement('div') ; 
    if(golink) { d.appendChild(golink) ; k = 1 ; } else k = 0 ; 
    if(dellink) { if(k) domadd(d,' : ') ; d.appendChild(dellink) ; k = 1 ; }
    if(updlink) { if(k) domadd(d,' : ') ; d.appendChild(updlink) ; }
    div.appendChild(d) ; 
  }

  ptno = d = routeno = 0 ; 
  return { div:div , scroller:setInterval(scroller,30) } ;

  function genpicimg(item,loadfunc)
  { var ind,img ;
    function serve(x)
    { if(x.substr(0,5)=='http:'&&document.URL.substr(0,6)=='https:')
        return fileserver + '?' + x ; 
      else return x ; 
    }

    if(!base.level) 
    { ind = item.ind ; 
      return genimage(sect[ind[0]].list[ind[1]],sizes,-1,loadfunc).img ;
    }
    img = domcreate('img',null,'src',serve(item.src)) ; 
    img.setAttribute('width',item.width) ; 
    img.setAttribute('height',item.height) ; 
    if(item.srcset) 
    { item = serve(item.srcset)+ ' ' + item.scale + 'x' ;
      img.setAttribute('srcset',item)  ;
    }
    if(loadfunc) img.onload = loadfunc ;
    return img ;
  }

  function scrolltype(i)
  { this.img = this.top = this.pos = null ; 
    this.ind = i ; // index into items
    this.wid = items[i].width ;
    this.addimage = function(pos)
    { var fetch=null,ind=this.ind ; 
      if(ind<items.length-1&&nfetched<=ind)
      { nfetched += 1 ; fetch = function() { genpicimg(items[ind+1],null) ; } }
      this.img = genpicimg(items[ind],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,ipts ; 

    if(base.level==1)
    { if(seg.level==1) ipts = seg.pts[routeno].pts ; else ipts = seg.pts ; 
      drawsel(ipts,ptno,d>0?d:null) ;
      for(d+=40;ptno<ipts.length-1;d-=offset,ptno++)
      { offset = ipts[ptno].delta ; if(d<offset) break ; }
      if(ptno==ipts.length-1) 
      { routeno += 1 ; 
        if(seg.level==1) if(routeno>=seg.pts.length) routeno = 0 ; 
        ptno = 0 ; 
        d = -400 ; 
      }
    }
    if(items.length<=2) return ; 

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

function btnicon(name)
{ var i ; 
  if(!btnicon.list) btnicon.list = [] ;
  for(i=0;i<btnicon.list.length&&btnicon.list[i][0]!=name;i++) ;
  if(i==btnicon.list.length) btnicon.list.push(buttons(name)) ; 
  return { black:btnicon.list[i][1] , grey:btnicon.list[i][2] } ;
}
function newcanvas(ratio)
{ var c = domcreate('canvas',null,'width',24*ratio) ; 
  c.setAttribute('height',24*ratio) ; 
  c.setAttribute('style','width:24px;height:24px') ; 
  return c ; 
}
function buttons(name)
{ var b=[name,null,null],i,j,colour,ctx,ratio=window.devicePixelRatio||1 ; 
  var theta,phi,psi,r,sign,twopi=2*Math.PI ;

  for(i=1;i<3;i++)
  { if(i==2) colour = '#bebebe' ; else colour = 'black' ;
    b[i] = newcanvas(ratio) ; 
    ctx = b[i].getContext('2d') ;
    ctx.strokeStyle = colour ; 
    ctx.fillStyle = colour ; 
    ctx.lineWidth = 0 ; 
    ctx.scale(ratio,ratio) ; // ratio is usually 1 or 2

    if(name=='settings'||name=='segment') 
    { ctx.lineWidth = 4 ; 
      ctx.beginPath() ; 
      ctx.fillStyle = 'white' ; 
      ctx.arc(12,12,6,0,twopi,false) ; 
      ctx.stroke() ; 

      ctx.lineWidth = 0 ; 
      ctx.fillStyle = colour ; 
      phi = twopi / 12 ;
      psi = twopi / 32 ;
      r = 11 / Math.cos(psi) ; 
      for(j=0;j<6;j++)
      { theta = j * twopi / 6 ;
        ctx.beginPath() ; 
        ctx.moveTo(12+7*Math.sin(theta-phi),12+7*Math.cos(theta-phi)) ;
        ctx.lineTo(12+r*Math.sin(theta-psi),12+r*Math.cos(theta-psi)) ;
        ctx.lineTo(12+r*Math.sin(theta+psi),12+r*Math.cos(theta+psi)) ;
        ctx.lineTo(12+7*Math.sin(theta+phi),12+7*Math.cos(theta+phi)) ;
        ctx.fill() ;
      }
      if(name=='segment')
      { ctx.fillStyle = 'white' ; 
        ctx.beginPath() ; 
        ctx.moveTo(12,12) ;
        ctx.lineTo(12,24) ;
        ctx.lineTo(24,24) ;
        ctx.lineTo(24,6) ; 
        ctx.fill() ;
      }
    }
    else if(name=='waypoint') 
    { ctx.beginPath() ; 
      ctx.moveTo(12,2) ;
      ctx.lineTo(3,23) ;
      ctx.lineTo(12,13) ;
      ctx.lineTo(21,23) ;
      ctx.fill() ;
    }
    else if(name=='scissors') for(j=0;j<2;j++)
    { if(j) sign = -1 ; else sign = 1 ; 
      ctx.fillStyle = colour ; 
      ctx.beginPath() ; 
      ctx.moveTo(12-5*sign,1) ;
      ctx.lineTo(12+3*sign,15) ;
      ctx.lineTo(12+1.5*sign,18) ;
      ctx.lineTo(12-6.6*sign,2.5) ;
      ctx.fill() ;

      ctx.beginPath() ; 
      ctx.ellipse(12+5.5*sign,18.5, 3.5,5, -sign*twopi/12, 0,twopi) ; 
      ctx.fill() ;

      ctx.fillStyle = 'white' ; 
      ctx.beginPath() ; 
      ctx.ellipse(12+5.5*sign,18.5, 2,3, -sign*twopi/12, 0,twopi) ; 
      ctx.fill() ;
    }
    else if(name=='pen') 
    { ctx.beginPath() ; 
      ctx.moveTo(16,1) ;
      ctx.lineTo(23,8) ;
      ctx.lineTo(17,12) ;
      ctx.lineTo(12,7) ;
      ctx.fill() ;

      ctx.beginPath() ; 
      ctx.moveTo(12,8) ;
      ctx.lineTo(16,13) ;
      ctx.arcTo(15,16,16,20,12) ;
      ctx.lineTo(2,24) ;
      ctx.lineTo(1.3,23.3) ;
      ctx.lineTo(8.3,16.15) ;
      ctx.lineTo(7.7,15.85) ;
      ctx.fill() ;

      ctx.beginPath() ; 
      ctx.moveTo(12,8) ;
      ctx.arcTo(9,9,5,8,12) ;
      ctx.lineTo(0,22) ;
      ctx.lineTo(0.7,22.7) ;
      ctx.lineTo(7.7,15.85) ;
      ctx.fill() ;

      ctx.fillStyle = 'white' ; 
      ctx.beginPath() ; 
      ctx.arc(9,15,1.5,0,twopi) ; 
      ctx.fill() ;
    }
    else if(name=='camera') 
    { ctx.beginPath() ; 
      ctx.moveTo(1,18) ;
      ctx.lineTo(1,8) ;
      ctx.lineTo(2,8) ;
      ctx.lineTo(3,6) ;
      ctx.lineTo(7,6) ;
      ctx.lineTo(8,8) ;
      ctx.lineTo(9,8) ;
      ctx.lineTo(12,5) ;
      ctx.lineTo(15,5) ;
      ctx.lineTo(19,8) ;
      ctx.lineTo(19,8) ;
      ctx.lineTo(20,7) ;
      ctx.lineTo(21,7) ;
      ctx.lineTo(22,8) ;
      ctx.lineTo(23,8) ;
      ctx.lineTo(23,18) ;
      ctx.lineTo(22,19) ;
      ctx.lineTo(2,19) ;
      ctx.fill() ;

      ctx.fillStyle = 'white' ; 
      ctx.lineWidth = 3 ; 
      ctx.beginPath() ; 
      ctx.arc(13.5,14,4.5,0,twopi) ; 
      ctx.stroke() ;
      ctx.fill() ;
    }
    else if(name=='undo'||name=='redo') 
    { if(name=='undo') sign = 1 ; else sign = -1 ; 
      // the arrowhead
      ctx.beginPath() ; 
      ctx.moveTo(12-sign*10,10) ;
      ctx.lineTo(12-sign*3,3) ;
      ctx.lineTo(12-sign*3,17) ;
      ctx.fill() ;

      // upper elliptical arc
      ctx.beginPath() ; 
      ctx.ellipse(12-sign*3,20, 12,13,0, (sign>0?0:-twopi/2),-twopi/4, sign>0) ;
      ctx.fill() ;

      // fill in triangle below
      ctx.beginPath() ; 
      ctx.moveTo(12-sign*3,7) ;
      ctx.lineTo(12+sign*3,13) ;
      ctx.lineTo(12+sign*9,20) ;
      ctx.lineTo(12-sign*3,20) ;
      ctx.fill() ;

      // lower elliptical arc
      ctx.fillStyle = 'white' ; 
      ctx.beginPath() ; 
      ctx.ellipse(12-sign*3,20, 12,7,0, (sign>0?0:-twopi/2),-twopi/4, sign>0) ; 
      ctx.fill() ;

      // unfill triangle below
      ctx.beginPath() ; 
      ctx.moveTo(12-sign*3,13) ;
      ctx.lineTo(12+sign*3,16) ;
      ctx.lineTo(12+sign*9,20) ;
      ctx.lineTo(12-sign*3,20) ;
      ctx.fill() ;
    }
    else if(name=='dl')
    { ctx.beginPath() ; 
      ctx.moveTo(12,19) ;
      ctx.lineTo(4,11) ;
      ctx.lineTo(8,11) ;
      ctx.lineTo(8,4) ;
      ctx.arcTo(8,2,10,2,2) ;
      ctx.lineTo(14,2) ;
      ctx.arcTo(16,2,16,4,2) ;
      ctx.lineTo(16,11) ;
      ctx.lineTo(20,11) ;
      ctx.fill() ;

      ctx.beginPath() ; 
      ctx.lineWidth = 2 ; 
      ctx.moveTo(2,18) ;
      ctx.lineTo(2,20) ;
      ctx.arcTo(2,22,4,22,2) ;
      ctx.lineTo(20,22) ;
      ctx.arcTo(22,22,22,20,2) ;
      ctx.lineTo(22,18) ;
      ctx.stroke() ;
    }
    else // account name=0=>not logged in, 1=>logged in no alerts, 2=>+alerts
    { if(!name) ctx.fillStyle = 'white' ; 
      ctx.lineWidth = 2 ; 
      ctx.beginPath() ;
      ctx.arc(12,7,5,0,twopi) ;
      ctx.fill() ;
      ctx.stroke() ;

      ctx.beginPath() ; 
      ctx.moveTo(2,22) ;
      ctx.lineTo(2,18) ;
      ctx.arcTo(2,14,6,14,4) ;
      ctx.lineTo(18,14) ;
      ctx.arcTo(22,14,22,18,4) ;
      ctx.lineTo(22,22) ;
      ctx.closePath() ;
      ctx.fill() ;
      ctx.stroke() ;
    }
  } 
  return b ; 
}
/* -------------------------------------------------------------------------- */

function cloneCanvas(oldCanvas) 
{ //create a new canvas
  var newCanvas = newcanvas(window.devicePixelRatio||1) ; 
  var context = newCanvas.getContext('2d') ;

  //apply the old canvas to the new one
  context.drawImage(oldCanvas,0,0) ;
  return newCanvas ;
}
function buttoncell(gif1,gif2,gif3) 
{ var td=domcreate('td',null,'style','padding-bottom:4px') ; 
  var nobr=domcreate('nobr',null,'style','padding-right:6px') ;
  var i,gifs=[gif1,gif2,gif3] ;
  for(i=0;i<3&&(gifs[i]==0||gifs[i]);i++)
  { nobr.appendChild(cloneCanvas(btnicon(gifs[i]).black)) ;
    domadd(nobr,' ') ; 
  }
  td.appendChild(nobr) ; 
  return td ;
}
function textcell(p1,p2) 
{ var td=domcreate('td',null,'style','padding-bottom:4px') ; 
  td.appendChild(domcreate('nobr',p1)) ;
  if(p2!=null&&p2!=undefined)
  { td.appendChild(document.createElement('br')) ;
    td.appendChild(domcreate('nobr',p2)) ;
  }
  return td ;
}
function appendrow(td,p)
{ td.appendChild(domcreate('nobr',p)) ;
  td.appendChild(document.createElement('br')) ;
}
function genlink(uri,legend,blank)
{ var a = domcreate('a',legend,'href',uri) ;
  a.setAttribute('style','cursor:pointer;color:#0000bd;text-decoration:none') ; 
  if(blank=='close') 
  { a.onclick = function() { infowindow.close() ; } ; return a ; }
  if(!blank) return a ; 

  a.setAttribute('target','_blank') ; 
  var s = domcreate('span',a) ; 
  domadd(s,' ') ; 
  s.appendChild(newtabdiv()) ;
  if(blank=='br') s.appendChild(document.createElement('br')) ;
  return s ;
}
function genindexlink(index,legend)
{ var s = trackref(routeprops.origin,index.href,1) ; 
  if(index.title) legend += ' ‘' + index.title + '’' ;
  return genlink(s,legend,'br') ;
}
function genspan(legend,bropt,spanstyle)
{ var span = document.createElement(bropt=='hr'?'div':'span'),s,ind=-1 ; 
  if(legend) ind = legend.search('#') ;
  if(ind<0) domadd(span,legend) ; 
  else
  { domadd(span,legend.substring(0,ind)+' ') ;
    s = domcreate('span','\u00a0\u00a0\u00a0\u00a0') ; 
    s.setAttribute('style','background-color:'+legend.substring(ind,ind+7)) ;
    span.appendChild(s) ;
    domadd(span,' '+legend.substring(ind+7)) ;
  }
  if(spanstyle==undefined) spanstyle = '' ; 
  else if(spanstyle!='') spanstyle += ';' ; 
  if(bropt==']br') { domadd(span,']') ; 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 genclickfn(action,legend,bropt,style)
{ var defstyle = 'cursor:pointer;color:#0000bd' ;
  if(style) defstyle += ';' + style ; 
  var span = genspan(legend,bropt,defstyle) ; 
  span.onclick = action ; 
  return span ;
}
/* -------------------------------------------------------------------------- */

function logout(opt)
{ infowindow.close() ; 
  if(opt&&!window.confirm(L.delwarn)) return ;
  var xhttp = new XMLHttpRequest() ;
  xhttp.onreadystatechange = function() 
  { var v ;
    if(xhttp.readyState==4) 
    { if(xhttp.response.length==0) { alert(L.dberr) ; return ; }
      if(xhttp.response=="**sqlerr: already") alert(L.alreadygone) ; 
      else if(xhttp.response!="Account deleted"&&xhttp.response!="Logged out") 
      { alert(xhttp.response) ; return ; }
      acbtn.ui.greytitle = acbtn.blacktitle = L.register[1]+'/'+L.register[0] ; 
      v = btnicon(0) ; 
      acbtn.blackimg = v.black ;
      acbtn.greyimg = v.grey ;
      redrawbtn(acbtn,-1) ; 
      prefs = defprefs ;
    }
  }
  xhttp.open("GET","resources/signup.php"+(opt?"?delact":"")) ;
  xhttp.send() ;
}
/* -------------------------------------------------------------------------- */

function acmenu(opt)
{ var d = document.createElement('div') ;

  function acfactory(ind) 
  { return function() 
    { infowindow.close() ; 
      infowindow.open(acfollowon(ind),getbtnpos(9),'account') ;
     } ; 
  } ;
  function logoutfactory(ind) { return function() { logout(ind) ; } ; }

  if(prefs.email)
  { d.appendChild(genspan(prefs.email,'hr')) ; 
    function doprefs() 
    { infowindow.close() ; textprompt(L.prefs+':',null,null,'prefs') ; }
    function bugreport()
    { window.open('mailto:colin.champion@routemaster.app?subject='+L.prob) ; }
    d.appendChild(genclickfn(doprefs,L.prefs,'br')) ; 
    d.appendChild(genclickfn(acfactory(3),L.register[3],'br')) ;
    d.appendChild(genclickfn(logoutfactory(0),L.logout,'br')) ; 
    d.appendChild(genclickfn(logoutfactory(1),L.logout+' '+L.delact,'br')) ; 
    d.appendChild(genlink('mailto:colin.champion@routemaster.app?'+
                  'subject=routemaster problem',L.bugreport,'close')) ; 
    return d ; 
  }
  d.appendChild(genclickfn(acfactory(0),L.register[0],'br')) ;
  d.appendChild(genclickfn(acfactory(1),L.register[1],'br')) ;
  d.appendChild(genclickfn(acfactory(2),L.register[2])) ;
  return d ; 
} 
/* -------------------------------------------------------------------------- */

// opt is 0 for register, 1 for login, 
// 2 for forgotten password, 3 for changing password
function acfollowon(opt)
{ var div=document.createElement('div'),d,i,p,formreq,optno ;
  var label,inp,pl,valid,pinp,cinp=null,sinp,minp,kinp ; 
  var sty = 'width:' + Math.max(L.submit.length,L.cancel.length) + 'em;' ;
  var styopts = [ 'color:silver' ,                            // invalid
                  'background-color:#e2e2e2;cursor:pointer' , // valid
                  'cursor:pointer' ] ;                        // cancel
  if(opt==3) 
  { if(prefs.email) acfollowon.email = prefs.email ; else return null ; }
  else if(opt<4) acfollowon.email = null ; 

  d = document.createElement('div') ;
  d.appendChild(domcreate('b',opt>=4?L.otkey:L.register[opt])) ; 
  d.appendChild(document.createElement('br')) ; 
  if(opt==0||opt==2) domadd(d,L.emailprompt) ; 
  else if(opt==1) domadd(d,inject2(L.pwdprompt,L.email,L.pwd)) ;
  else if(opt==3) domadd(d,L.aloneprompt) ;
  else domadd(d,inject2(L.pwdprompt,L.otk,L.pwd)) ;
  div.appendChild(d) ; 

  function greyit() 
  { sinp.setAttribute('style',sty+styopts[valid==7?1:0]) ; }

  function emailvalidate()
  { var i = this.value.indexOf('@') , ov = valid , j=-1 ; 
    if(i>1) j = this.value.substring(i+2).indexOf('.') ; 
    if(j>=0&&i+2+j<this.value.length-1) valid |= 1 ; else valid &= ~1 ;
    if(valid!=ov) greyit() ; 
  }
  function pwdvalidate()
  { var ov = valid ; 
    if(pinp.value.length>=6&&(!cinp||pinp.value==cinp.value)) valid |= 4 ; 
    else valid &= ~4 ; 
    if(valid!=ov) greyit() ; 
  }
  function otkvalidate()
  { var ov = valid , intval = parseInt(this.value) ; 
    if(intval>=100000000&&intval<1000000000) valid |= 2 ; else valid &= ~2 ; 
    if(valid!=ov) greyit() ; 
  }
  if(opt==0||opt==2) opts = [0] ; // send username alone to get otkey
  else if(opt==1) opts = [0,2] ;  // login: send username and password
  else if(opt==3) opts = [2,3] ;  // update password: request pwd + confirmation
  else if(opt==4||opt==5) opts = [1,2,3] ; // send otkey and password

  var f = domcreate('form',null,'method','POST') ; 
  f.setAttribute('enctype','multipart/form-pts') ;
  f.setAttribute('name','fileinfo') ; 
  div.appendChild(f) ; 
  // build up the html form
  for(valid=7,optno=0;optno<opts.length;optno++)
  { i = opts[optno] ;
    valid &= ~ (1<<i) ;
    if(i==0) { name = 'email' ; pl = L.email ; }
    else if(i==1) { name = 'otkey' ; pl = L.otk ; }
    else if(i==2) { name = 'password' ; pl = L.pwd ; }
    else if(i==3) { name = 'confpwd' ; pl = L.confpwd ; }
    inp = domcreate('input',null,'name',name) ; 
    if(i!=1) inp.setAttribute('type',i<2?'email':'password') ; 
    if(i>=2) inp.setAttribute('minlength',6) ; 
    inp.setAttribute('placeholder',pl) ; 
    inp.setAttribute('style','width:20em') ; 
    if(i==0) { minp = inp ; inp.oninput = emailvalidate ; }
    else if(i==1) { kinp = inp ; inp.oninput = otkvalidate ; }
    else if(i==2) { pinp = inp ; inp.oninput = pwdvalidate ; }
    else { cinp = inp ; inp.oninput = pwdvalidate ; }
    f.appendChild(domcreate('div',inp)) ; 
  }

  // add the cancel/submit buttons
  d = document.createElement('div') ;
  for(i=0;i<2;i++) 
  { inp = domcreate('input',null,'type',i?'submit':'button') ; 
    inp.setAttribute('name',i?'submit':'cancel') ; 
    inp.setAttribute('value',i?L.submit:caps(L.cancel)) ;
    inp.setAttribute('style',sty+styopts[i==0?2:0]) ; 
    if(i==1) { sinp = inp ; inp.onclick = formresponse ; }
    else inp.setAttribute('onclick','infowindow.close()') ; 
    d.appendChild(inp) ; 
  } 

  d.setAttribute('style','width:13em;margin:auto') ;
  div.appendChild(d) ; 
  div.appendChild(doclink('account',L.accdoc)) ; 
  p = domcreate('p',L.cookies) ;
  p.setAttribute('style','margin-top:2px;margin-bottom:2px;font-size:90%') ; 
  div.appendChild(p) ; 
  div.onkeydown = function(e)
  { if(e.keyCode==13||e.which==13) { e.preventDefault() ; formresponse() ; } }

  return div ; 

  // the submit button invokes formresponse which sends the form, and when a
  // reply is returned it triggers phpresponse

  function loggedin(s)
  { var v = btnicon(1) ; 
    acbtn.blackimg = v.black ;
    acbtn.greyimg = v.grey ;
    redrawbtn(acbtn,-1) ; 
    s = s.split('|') ; 
    speaklang(s[2]) ; 
    prefs = { email:     s[0] , 
              instance:  parseInt(s[1]) ,
              lang:      s[2] , 
              detail:    parseInt(s[3]) ,
              optim:     parseInt(s[4]) ,
              maxsep:    parseInt(s[5]) ,
              precision: parseInt(s[6]) ,
              gpshits:   s[7].split(' ') , 
              pixhits:   s[8].split(' ')
            }
    acbtn.ui.greytitle = acbtn.blacktitle = L.account ; 
  }
  function phpresponse(event)
  { var s=event.target.response,i ;
    if(formreq.status!=200) alert(L.formerr+formreq.status) ; 
    else if(s.substring(0,17)=="**sqlerr: already") 
      alert(inject(L.already,acfollowon.email)) ; 
    else if(s.substring(0,18)=="**sqlerr: wrongpwd") alert(L.incorrect) ; 
    else if(s.substring(0,18)=="**sqlerr: bademail") alert(L.bademail) ; 
    else if(s.substring(0,18)=="**sqlerr: badotkey") alert(L.badotkey) ; 
    else if(s.substring(0,20)=="**sqlerr: baduser") 
      alert(inject(L.baduser,acfollowon.email)) ; 
    else if(s.substring(0,17)=="**sqlerr: expired") alert(L.expired) ; 
    else if(s.substring(0,17)=="**sqlerr: toosoon") 
      alert(inject(L.toosoon,acfollowon.email)) ; 
    else if(s.substring(0,10)=="**sqlerr: ") alert(L.dberr+s.substring(9)) ; 
    else if(opt==1||opt==4||opt==5) loggedin(s) ; 
    else if(opt==0||opt==2) 
    { infowindow.close() ;
      infowindow.open(acfollowon(opt==0?4:5),getbtnpos(9),'account') ;
    }
  }
  function formresponse(ev) 
  { infowindow.close() ; 
    var formdata = new FormData(f) ; 
    formdata.append("option",opt) ;
    if(opts[0]==0) acfollowon.email = minp.value ; // pull out email
    else formdata.append("email",acfollowon.email) ; 
    formreq = new XMLHttpRequest() ;
    formreq.open("POST","resources/signup.php") ;
    formreq.onload = phpresponse ;
    formreq.send(formdata) ;
    if(ev) ev.preventDefault() ;
  } ;
}
/* -------------------------------------------------------------------------- */

function blurbdiv(uri)
{ var div=document.createElement('div') ;
  var url = document.location.href , i = url.indexOf('?') , bus = 'bus.png' ; 
  if(i>=0) url = url.substring(0,i) ; 
  if(url.substring(url.length-8)=='test.php') bus = 'greenbus.png' ;
  var img = domcreate('img',null,'src','resources/'+bus) ; 
  img.setAttribute('width',16) ; 
  img.setAttribute('height',16) ; 
  img.setAttribute('style','margin-top:4px;position:relative;bottom:-2px') ; 
  div.appendChild(img) ;
  div.appendChild(domcreate('b','\u00a0\u00a0'+L.rmgps)) ;
  domadd(div,' '+L.rmdisplays) ;
  return div ; 
}
/* -------------------------------------------------------------------------- */

function rmhelpdiv(mode)
{ var div=document.createElement('div'),d,t,tr,td,a,s,i,lim ; 

  function addbull(node,item)
  { node.appendChild(domcreate('li',L.funcs[item])) ; }

  if(!mode) mode = 0 ; 
  t = domcreate('table',null,'style','font-size:100%') ;
  t.setAttribute('cellpadding',0) ; 
  t.setAttribute('cellspacing',0) ; 

  if(mode==1)
  { tr = domcreate('tr',buttoncell('settings','dl')) ; 
    tr.appendChild(textcell(L.setsave)) ; 
    t.appendChild(tr) ; 

    tr = domcreate('tr',buttoncell('undo','redo')) ; 
    tr.appendChild(textcell(L.unre)) ; 
    t.appendChild(tr) ; 
  }
  else
  { tr = domcreate('tr',buttoncell('settings','segment','waypoint')) ; 
    tr.appendChild(textcell(L.propsdoc[0],L.propsdoc[1])) ;
    t.appendChild(tr) ; 

    tr = domcreate('tr',buttoncell('scissors','pen','camera')) ; 
    tr.appendChild(textcell(L.scisdoc[0],L.scisdoc[1])) ;
    t.appendChild(tr) ; 

    tr = domcreate('tr',buttoncell('undo','redo','dl')) ; 
    tr.appendChild(textcell(L.unre+'/'+L.save)) ; 
    t.appendChild(tr) ; 
  }
  /* ------------------------------------------------------------------------ */

  tr = domcreate('tr',buttoncell(prefs.email?1:0)) ; 
  tr.appendChild(textcell(L.logindoc[0],L.logindoc[1])) ; 
  t.appendChild(tr) ; 

  tr = domcreate('tr',domcreate('td','Keyboard: ','valign','top')) ; 

  td = document.createElement('td') ;
  d = document.createElement('div') ;
  if(mode==1) appendrow(d,L.delbksp) ;
  else
  { appendrow(d,'\u2190/\u2192 '+L.wpmove) ;
    appendrow(d,inject2('[%0 \u2190]/[%1 \u2192] '+L.segmove,L.shift,L.shift)) ;
    appendrow(d,'\u2193 '+L.centres) ;
    appendrow(d,L.spacedoc,1) ;
    d.setAttribute('style','margin-bottom:6px') ; 
    td.appendChild(d) ; 
    d = document.createElement('div') ;
    appendrow(d,inject(L.tabdoc,L.dragfor)) ;
    appendrow(d,inject(L.stabdoc,L.dragback)) ;
    appendrow(d,L.deldoc) ;
    appendrow(d,L.sdeldoc) ;
  }
  d.setAttribute('style','margin-bottom:6px') ; 
  td.appendChild(d) ; 
  tr.appendChild(td) ; 
  t.appendChild(tr) ; 

  if(mode<1)
  { tr = domcreate('tr',domcreate('td',L.mouse+' ','valign','top')) ; 
    td = document.createElement('td') ;
    appendrow(td,L.clickdoc) ;
    appendrow(td,L.sclickdoc) ;
    tr.appendChild(td) ; 
    t.appendChild(tr) ; 
  }

  div.appendChild(underline(domcreate('div',t))) ; 

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

  d = document.createElement('div') ;
  if(mode<0)
  { s = 'https://www.routemaster.app/?track=https://www.masterlyinactivity.com';
    a = genlink(s+'/routemaster/routes/capeverde/Caibros.gpx',L.extrack) ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
    a = genlink(s+'/routemaster/routes/capeverde/index.tcx',L.exindex) ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
  }

  a = genlink('https://www.masterlyinactivity.com/software/routemaster.html',
              L.userman) ; 
  if(mode>=0) a.setAttribute('target','_blank') ;
  a.setAttribute('rel','nofollow') ;
  d.appendChild(a) ; 
  if(mode>=0) { domadd(d,' ') ; d.appendChild(newtabdiv()) ; }
  else d.setAttribute('style',
      'margin-bottom:2px;border-bottom:solid 1px silver;padding-bottom:2px') ; 
  div.appendChild(d) ; 

  if(mode<0)
  { d = domcreate('p',L.rmfns) ;
    d.setAttribute('style','font-weight:bold;margin:3 0') ; 
    div.appendChild(d) ;

    lim = Math.floor((L.funcs.length+1)/2) ;
    d = document.createElement('div') ;
    d.setAttribute('style',
                   'max-width:45%;padding-right:5%;display:inline-block') ; 
    for(i=0;i<lim;i++) addbull(d,i) ;
    div.appendChild(d) ;

    d = document.createElement('div') ;
    d.setAttribute('style',
                   'max-width:45%;padding-left:5%;display:inline-block') ; 
    for(;i<L.funcs.length;i++) addbull(d,i) ;
    div.appendChild(d) ;
  }
  /* ------------------------------------------------------------------------ */

  d = domcreate('div',L.rmtc[0]) ;
  d.appendChild(genlink('https://maps.google.com/help/terms_maps.html',
                        L.rmtc[1],1)) ; 
  d.setAttribute('style','font-size:90%;color:gray;margin-top:2px;'+
                         'border-top:solid 1px silver;padding-top:2px') ; 
  div.appendChild(d) ; 

  return div ; 
}
/* -------------------------------------------------------------------------- */
/*            THE route props MENU AND THE FUNCTIONS IT GOVERNS               */
/* -------------------------------------------------------------------------- */

function genpixlink(gallery,sty)
{ var tag ;
  if(gallery.title) tag = '‘' + gallery.title + '’' ; else tag = L.photos ;
  tag = inject(L.view,tag) ; 
  return genlink(gallery.href,tag,sty) ; 
}
function cogwheelmenu(dragopt)
{ var d = document.createElement('div') , dd , gallery=getgallery() , legend ;
  var starsdiv , maxsep ; 
  function addloadfactory(ind) { return function() { addload(ind) ; } ; } ;
  function dlfactory(ind) { return function(e) { dl(ind,e) ; } ; } ;
  function arfactory(ind) { return function() { arfunc(ind) ; } ; } ;

  // dragging
  if(dragopt) { d.appendChild(genspan(L.hitspace,'br')) ; return d ; }

  if(segments[0].level)
  { d.appendChild(tabulate()) ;
    dd = titlediv('title',segments[0].title,1,0) ;
    dd.setAttribute('style','margin-top:4px') ; 
    d.appendChild(dd) ;
    if(segments[0].level!=2&&segments[0].index) 
      d.appendChild(genindexlink(segments[0].index,L.viewidx)) ;
    if(segments[0].level!=2&&gallery) 
      d.appendChild(genindexlink(gallery,L.view)) ;
    if(segments[0].level==1) legend = L.addroute ; else legend = L.addindex ;
    d.appendChild(genclickfn(addloadfactory("add"),legend,'br')) ;
    if(segments[0].level==1)
    { d.appendChild(genclickfn(dlfactory(2),L.savemeta,'br')) ; 
      if(showarrows) 
        d.appendChild(genclickfn(arfactory(0),L.hidear,'br')) ; 
      else d.appendChild(genclickfn(arfactory(1),L.showar,'br')) ; 
      d.appendChild(genclickfn(listroutes,L.listroutes,'hr')) ; 
    }
    d.appendChild(genclickfn(help,L.help,'br')) ; 
    return d ; 
  }

  var s,i,unsaved,props,spacing,tdiv,p,rp=routeprops,loadno,pr ;
  dd = document.createElement('div') ;

  for(loadno=nactions-1;
      loadno>=0&&actions[loadno][0]!='load'&&actions[loadno][0]!='add';
      loadno--) ; 
  if(loadno>=0) pr = actions[loadno][3] ; else pr = null ; 
  // calculate route properties 
  p = routestats(segments) ; 

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

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

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

  // description
  dd.appendChild(titlediv('desc',rp.desc,rp.origin)) ;
  underline(dd) ; 
  d.appendChild(dd) ; 

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

  if(routeprops.info||routeprops.index||gallery) dd = domcreate('div') ; 

  // index
  if(routeprops.index) 
    dd.appendChild(genindexlink(routeprops.index,L.viewidx)) ;

  // info page
  if(routeprops.info) dd.appendChild(genindexlink(routeprops.info,L.viewinfo)) ;

  // gallery
  if(gallery) dd.appendChild(genpixlink(gallery,'br')) ;
  
  if(routeprops.info||routeprops.index||gallery) 
  { underline(dd) ; d.appendChild(dd) ; }

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

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

  // are there any missing altitudes?
  if(p.nnull) 
  { d.appendChild(genspan(pluralise(L.noalts,p.nnull)+'  [')) ;
    if(altthresh==null||altthresh>1) 
      d.appendChild(genclickfn(doalts,L.findalts,']br')) ;
    else d.appendChild(genspan(L.waitalts,']br','text-style:italic')) ;
  }

  // are points timed and are they in sequence?
  if(p.outoforder==0) 
  { if(p.ntimes==0) d.appendChild(genspan(L.notimes,'br')) ;
    else if(p.ntimes<p.npts) 
      d.appendChild(genspan(pluralise(L.untimed,p.npts-p.ntimes),'br')) ;
  }
  else d.appendChild(genspan(L.badtimes,'br')) ;

  // labels and photos
  if(p.nlabels>0) 
  { d.appendChild
      (genspan(pluralise(L.numlabels,p.nlabels)+' ['))
    d.appendChild(genclickfn(unlabel,L.remove,']br')) ;
  }
  if(p.pix.length>0) 
    d.appendChild(genspan(pluralise(L.numphotos,p.pix.length),'br')) ;

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

  // unsaved changes
  unsaved = unsavedchanges.length ; 
  if(unsaved>0) 
    d.appendChild(genspan(pluralise(L.unsavedchanges,unsaved),'br')) ;

  // number of segments - option to combine
  if(segments.length>1) 
  { d.appendChild(genspan(pluralise(L.numsegments,segments.length)+' [')) ;
    d.appendChild(genclickfn(combine,L.combine,']br')) ;
    d.appendChild(genspan(L.combnote,'br','font-style:italic')) ;
  }
  
  // max waypoint separation - option to interpolate
  d.appendChild(genspan(inject(L.maxsep,p.maxsep.toFixed(0)),'br')) ;
  if(prefs&&!prefs.maxsep) maxsep = null ; else maxsep = 100 ; 
  if(maxsep&&p.maxsep>=maxsep) 
  { d.appendChild(genspan(L.sepwarn,'br','font-style:italic')) ;
    d.appendChild(genspan('\u00a0\u00a0\u00a0[')) ;
    d.appendChild(genclickfn(extrapts,caps(L.interpextra),']br')) ;
  }

  // operations
  var dd=document.createElement('div') ; 
  dd.appendChild(genclickfn(addloadfactory("load"),L.loadnewroute,'br')) ; 
  dd.appendChild(genclickfn(addloadfactory("add"),caps(L.loadnewseg),'br')) ;
  dd.appendChild(genclickfn(dlfactory(1),L.saveidx,'hr')) ; 

  // help menu
  dd.appendChild(genclickfn(help,L.help,'br')) ; 
  dd.setAttribute('style','display:block') ; 
  dd.setAttribute('style',
    'margin-top:2px;border-top:solid 1px silver;padding-top:2px;') ; 

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

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

function deltimesfactory(segno,opt) 
{ if(opt) return function() {delalts(segno);} ; 
  else return function() {deltimes(segno);} ; 
} ;

function seginfodiv()
{ var d = document.createElement('div') , segno = selected[0] ;
  var props=segments[segno],span,prose,div,p,sty ; 
  function swapsegfactory(ind) { return function() { swapseg(ind) ; } ; } ;

  d.appendChild(genspan(inject2(L.segment,segments[segno].colour+segno,
                        segments.length,segments[segno].pts.length),'br')) ;

  if(props.title&&props.title!=routeprops.title)
    d.appendChild(titlediv('title',props.title,1,segno)) ; 

  if(props.stars&&props.desc&&props.desc!=routeprops.desc) 
    d.appendChild(starsline(props.stars,0)) ;
  if(props.srcid&&props.srcid!=props.title&&props.srcid!=routeprops.srcid) 
    d.appendChild(textdiv(L.source,props.srcid)) ;
  if(props.desc&&props.desc!=routeprops.desc) 
    d.appendChild(titlediv('desc',props.desc,null,segno)) ; // no links
  if(props.stats) d.appendChild(prettystats(props.stats)) ;

  // number of track points and optimisation
  function optimfactory(segno) { return function() { optimwork(segno) ; } ; } ;
  div = document.createElement('div') ;
  if(props.optim&&props.optim.already)  
    div.appendChild(genspan(L.prevopt,'br')) ;
  else if(props.optim) d.appendChild(genspan(L.optimised,'br')) ;
  else div.appendChild(genclickfn(optimfactory(segno),L.optimise,'br')) ;
  sty = 'display:block;margin-bottom:2px;'+
                   'border-bottom:solid 1px silver;padding-bottom:2px' ;
  if(props.desc&&!props.stats) sty += ';margin-top:2px;'+
                   'border-top:solid 1px silver;padding-top:2px' ;
  div.setAttribute('style',sty) ; 
  d.appendChild(div) ; 

  div = domcreate('div',d) ;

  p = routestats([segments[segno]]) ; 
  d = genldiv(p) ; 
  d.setAttribute('style','display:block') ; 
  div.appendChild(d) ; 

  d = document.createElement('div') ;
  if(segments.length>1) 
    d.appendChild(genclickfn(discard,caps(L.deletesegment),'br')) ; 
  else d.appendChild(genspan(caps(L.deletesegment),'br','color:silver')) ;
  d.appendChild(genclickfn(revseg,caps(L.revsegment),'br')) ;
  d.appendChild(genclickfn(dupseg,caps(L.dupsegment),'br')) ;

  if(segno>0)
  { d.appendChild(genclickfn(swapsegfactory(segno-1),
               inject2(L.swapwith,segments[segno-1].colour,L.preceding),'br')) ;
    if(segno<segments.length-1) prose = 'br' ; else prose = 'hr' ;
    d.appendChild(genclickfn(combineb,
           inject2(L.combinewith,segments[segno-1].colour,L.preceding),prose)) ;
  }
  if(segno<segments.length-1)
  { d.appendChild(genclickfn(swapsegfactory(segno),
               inject2(L.swapwith,segments[segno+1].colour,L.following),'br')) ;
    d.appendChild(genclickfn(combinef,
            inject2(L.combinewith,segments[segno+1].colour,L.following),'hr')) ;
  }

  if(p.ntimes) d.appendChild(genclickfn(deltimesfactory(segno,0),
                  caps(L.deltimes),'br')) ;
  else d.appendChild(genspan(L.nodeltimes,'br','color:silver')) ;

  if(getalts.reqlist&&getalts.reqlist.length) 
    d.appendChild(genspan('['+L.waitalts+']','br','color:grey')) ; 
  else if(p.nnull==segments[segno].pts.length) d.appendChild(
       genclickfn(googlecal,L.askgoogle,'br')) ; 
  else d.appendChild(genclickfn(altinfo,L.adjalts,'br')) ; 

  d.setAttribute('style','display:block;clear:left;margin-top:2px;'+
                 'border-top:solid 1px silver;padding-top:2px') ; 
  div.appendChild(d) ;

  return div ; 
}
/* -------------------------------------------------------------------------- */

function altinfodiv()
{ var d = domcreate('div',genclickfn(googlereg,L.regress,'br')) ; 
  d.appendChild(genclickfn(googleadd,L.googleadd,'br')) ; 
  d.appendChild(genspan('['+L.vsgoogle+']','br',
                        'font-size:80%;margin-left:8px;color:grey')) ; 
  d.appendChild(genclickfn(googlecal,L.repalts,'br')) ; 
  d.appendChild(genclickfn(manualcal,L.offalts,'br')) ; 
  d.appendChild
    (genclickfn(deltimesfactory(selected[0],1),caps(L.delalts),'br')) ;  
  d.appendChild(doclink('adjusting',L.docalts)) ; 
  return d ; 
}
function doclink(target,string)
{ var dd = document.createElement('div'),a ;
  a = genlink('https://www.masterlyinactivity.com/software/routemaster.html#'+
              target,string) ; 
  a.setAttribute('target','_blank') ;

  dd.appendChild(a) ; 
  domadd(dd,' ') ; 
  dd.appendChild(newtabdiv()) ; 
  dd.setAttribute('style','display:block') ; 
  dd.setAttribute('style',
    'margin-top:2px;border-top:solid 1px silver;padding-top:2px;') ; 
  return dd ;
}
/* -------------------------------------------------------------------------- */

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

  if(pt.label)
  { dd = document.createElement('div') ;
    s = icons.names.indexOf(pt.label) ;
    if(s<0) s = L.labels[0] ; else s = L.labels[s] ; 
    if(pt.marker.title) s += ': ' + pt.marker.title ;
    domadd(dd,s+' [') ;
    dd.appendChild(genclickfn(labelprompt,L.edit,']br')) ;
    if(pt.photo.length>0) underline(dd) ; 
    d.appendChild(dd) ; 
  }

  for(ind=0;ind<pt.photo.length;ind++)
  { dd = document.createElement('div') ;
    if(imginfo.status!='ready') k = null ; else k = findimg(pt.photo[ind]) ;
    function displayfactory(ind) { return function() { display(ind) ; } ; } ;
    if(k) 
    { imgdiv = document.createElement('div') ;
      s = genimage(imginfo.sect[k[0]].list[k[1]],imginfo.sizes,-1) ;
      imgdiv.appendChild(s.img) ; 
      if(imginfo.sect[k[0]].list[k[1]].extn=='.mp4')
        imgdiv.appendChild(mp4icon()) ; 
      imgdiv.setAttribute('style','position:relative;cursor:pointer;width:'+
                                   s.shape[0]+';height:'+s.shape[1]) ; 
      imgdiv.onclick = displayfactory(ind) ; 
      dd.appendChild(imgdiv) ; 
      dd.appendChild(document.createElement('br')) ;
    }
    else 
    { s = L.photo + ' ' + pt.photo[ind] + ' (' ;
      if(imginfo.status=='null') s += L.nolist ;
      else if(imginfo.status=='ready') 
        s += inject(L.notpres,shortenname(imginfo.uri)) ; 
      else if(imginfo.status=='waiting') s += inject(L.notavail,imgname) ;
      else s += 'imginfo.status = ' + imginfo.status ; 
      domadd(dd,s+ ') ') ;
    }
    domadd(dd,'[') ; 
    function pheditfactory(k) { return function() { photoedit(k) ; } ; } ;
    dd.appendChild(genclickfn(pheditfactory(ind),L.edit)) ;
    domadd(dd,']') ; 
    if(k)
    { domadd(dd,' : [') ; 
      function phinfofactory(k) { return function() { phinfo(k) ; } ; } ;
      dd.appendChild(genclickfn(phinfofactory(k),L.info)) ;
      domadd(dd,']') ; 
    }
    d.appendChild(underline(dd)) ; 
  }

  if(pt.photo.length>0) 
  { dd = domcreate('div','[') ; 
    dd.appendChild(genclickfn(photoprompt,caps(L.addphoto),']br')) ;
    d.appendChild(dd) ; 
  }
  return d ; 
}
/* -------------------------------------------------------------------------- */

function tabulate()
{ var ncol,nrow,seg,len,bkg,s,sno,r,sortl,gotroutes,ind,route,maxasc ;
  var t=document.createElement('table'),tr,td,i,j,k,l,item,stats ; 
  var maxlen,minlen,thresh ;
  var csty = 'padding:2px 4px;white-space:nowrap;font-size:80%;' ;
  var base = segments[0] , n = base.pts.length ; 
  ncol = Math.floor(Math.sqrt(n/4.5)) ; 
  t.setAttribute('cellspacing',0) ;
  t.setAttribute('cellpadding',0) ;
  if(ncol<1) ncol = 1 ; 
  nrow = Math.floor((n+ncol-1)/ncol) ;

  for(sortl=new Array(n),maxasc=gotroutes=i=0;i<n;i++)
  { route = base.pts[i] ;
    if(route.level>0) gotroutes = 1 ; 
    sortl[i] = { ind:i , title: route.title } ;
    if(route.stats[2]>maxasc) maxasc = route.stats[2] ;
    if(i==0||route.stats[1]>maxlen) maxlen = route.stats[1] ; 
    if(i==0||route.stats[1]<minlen) minlen = route.stats[1] ; 
  }      
  sortl.sort(function(a,b) { return a.title>b.title ; }) ;
  if(maxlen<9999) thresh = 10000 ; // always print dist in m if all routes short
  else thresh = 2000 ; // only print in m for short routes if there are long 1s

  for(i=0;i<nrow;i++)
  { tr = document.createElement('tr') ;
    for(j=0;j<ncol&&(k=j+i*ncol)<n;j++)
    { ind = sortl[k].ind ;
      route = base.pts[ind] ;
      if(j)
      { td = domcreate('td','\u00a0\u00a0\u00a0\u00a0') ;
        td.setAttribute('style','width:18px') ;
        tr.appendChild(td) ;
      }

      td = document.createElement('td') ;
      if(route.pts.length>1&&route.shades.length) 
        bkg = '-image: linear-gradient(to bottom right,' + 
                            route.shades[0] + ', ' + route.shades[1] + ')' ;
      else bkg = ':' + route.colour ; 
      td.setAttribute('style',csty+'width:14px;background'+bkg) ;
      domadd(td,'\u00a0\u00a0\u00a0\u00a0') ;
      tr.appendChild(td) ;
      td = document.createElement('td') ;
      function highfactory(ind) 
      { return function() { infowindow.close() ; highlight(0,ind) ; } ; } ;
      td.appendChild(genclickfn(highfactory(ind),route.title,0,
                                csty+'white-space:nowrap')) ;
      tr.appendChild(td) ;

      function addcell(s)
      { var td = domcreate('td',null,'style',csty+'text-align:right') ;
        td.appendChild(s) ;
        tr.appendChild(td) ;
      }

      // stars
      for(s='',sno=0;sno<route.stars;sno++) s += ' \u2605' ;
      addcell(document.createTextNode(s)) ;

      // number of routes
      if(gotroutes) for(l=0;l<2;l++) // loop over 2 cols
      { if(route.level>0) s = l?L.routes:route.pts.length ; else s = ' ' ; 
        addcell(document.createTextNode(s)) ; 
      }

      // distance, ascent
      if(route.stats[1]>thresh) addcell(prettynum(route.stats[1]/1000,0,'km')) ; 
      else addcell(prettynum(route.stats[1],0,'m')) ; 
      addcell(prettynum(route.stats[2],maxasc>9999,'m')) ; 
    }
    t.appendChild(tr) ;
  }
  return t ; 
}
/* -------------------------------------------------------------------------- */

function listroutes()
{ var index = segments[0] , n=index.pts.length,props,s='<a href="',sortl,i ;

  for(sortl=new Array(n),i=0;i<n;i++)
    sortl[i] = { ind:i , title: index.pts[i].title } ;
  sortl.sort(function(a,b) { return a.title>b.title ; }) ;

  s += trackref(routeprops.origin,null,1) + '&mode=z">index</a>\n' ;

  for(i=0;i<n;i++)
  { props = index.pts[sortl[i].ind] ;
    s += ' : <a href="' + trackref(routeprops.origin,props.tlink,1) + 
         '&mode=z">' + props.title + '</a>' ;
  }
  s += '\n' ; 
  saveAs(new Blob([s],{type: "text/plain;charset=utf-8"}),'list.html') ;
}
/* -------------------------------------------------------------------------- */
/*           WPINFO IS A MENU GIVING ACCESS TO THE SETALT FUNCTION            */
/* -------------------------------------------------------------------------- */

function wpinfodiv(precision) 
{ var s0=selected[0],s1=selected[1],alt,s,x,lat,lng,time,c,ctx,grad ;
  var pt = segments[s0].pts[s1] , pos = pt.pos , npt=null , step , dd ;
  var s , dt , dh , nalt = null , d = document.createElement('div') ;
  var ratio = window.devicePixelRatio||1 ;
  if(s1<segments[s0].pts.length-1) 
  { npt = segments[s0].pts[s1+1] ; 
    step = pt.delta ; 
    nalt = npt.h ; 
  }

  // position
  lat = pos.lat() ; 
  lng = pos.lng() ; 
  if(lat>=0) 
    s = lat.toFixed(5+precision) + inject('\u00b0 %, ',L.nsew.charAt(0)) ; 
  else 
    s = (-lat).toFixed(5+precision) + inject('\u00b0 %, ',L.nsew.charAt(1)) ; 
  if(lng>=0) 
    s += lng.toFixed(5+precision) + inject('\u00b0 %. ',L.nsew.charAt(2)) ; 
  else 
    s += (-lng).toFixed(5+precision) + inject('\u00b0 %. ',L.nsew.charAt(3)) ; 
  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 = 10 + 2 * Math.floor((precision+1)/2.0) ;
    s = L.osref + ': ' + OsGridRef.latLonToOsGrid(x).toString(s) ;
  }
  else s = L.utm + ': ' + x.toUtm(precision) ; 
  d.appendChild(genspan(s,'br')) ;

  // altitude
  alt = pt.h ;

  function altfactory(parm) 
  { return function() { setalt(parm,prefs.precision) ; } ; } ;

  if(alt!=null) 
  { d.appendChild(genspan(caps(L.alti)+': '+alt.toFixed(precision) + 'm [')) ;
    d.appendChild(genclickfn(altfactory(1),L.edit,']br')) ;
  }
  else d.appendChild(genclickfn(altfactory(0),caps(L.wpalt),'br')) ;

  // date and time
  time = pt.t ;
  if(time!=null&&time.getFullYear()>1980) 
  { d.appendChild(genspan(L.date+': '+time.toDateString(),'br')) ;
    d.appendChild(genspan(L.time+': '+time.toTimeString(),'br')) ;
  }

  // marker
  if(pt.label) 
  { s = pt.label ;
    if(pt.marker.title) s += ': ' + pt.marker.title ;
    d.appendChild(genspan(s+' [')) ;
    d.appendChild(genclickfn(labelprompt,L.edit,']br')) ;
  }

  // gradient + speed
  grad = '' ; 
  if(npt&&pt.t&&npt.t) dt = npt.t.getTime() - time.getTime() ; else dt = null ; 
  if(alt!=null&&nalt!=null) dh = nalt - alt ; else dh = null ; 

  if(dh!=null&&step>0) 
  { c = domcreate('canvas',null,'width',12*ratio) ; 
    c.setAttribute('height',12*ratio) ; 
    c.setAttribute('style','width:12px;height:12px') ; 
    ctx = c.getContext("2d") ;
    ctx.fillStyle = segments[s0].colour ; 
    ctx.strokeWidth = 0 ;
    ctx.scale(ratio,ratio) ;
    ctx.beginPath() ; 

    if(4*Math.abs(alt-nalt)<step)
    { ctx.moveTo(0,6*(1+4*dh/step)) ;
      ctx.lineTo(12,6*(1-4*dh/step)) ;
      ctx.lineTo(12,12) ; 
      ctx.lineTo(0,12) ; 
    }
    else 
    { ctx.moveTo(6*(1-step/(4*dh)),12) ; 
      ctx.lineTo(6*(1+step/(4*dh)),0) ; 
      if(dh>0) { ctx.lineTo(12,0) ; ctx.lineTo(12,12) ; }
      else { ctx.lineTo(0,0) ; ctx.lineTo(0,12) ; }
    }
    ctx.fill() ;
    grad = (100*dh/step).toFixed(0) + '%' ;
  }

  if(grad!=''||dt>0) 
  { s = domcreate('div',grad,'style','padding-right:5px;'+
                  'display:inline-block;width:34px;text-align:right') ;
    dd = domcreate('div',s) ; 
    if(grad!='') domadd(dd,c) ; 
    if(npt&&pt.t&&npt.t) if((x=npt.t.getTime()-time.getTime())>0)
      domadd(dd,' '+(3600*step/x).toFixed(1)+L.kmh) ;
    domadd(dd,' (→'+step.toFixed(precision)+'m)') ;
    domadd(d,dd) ; 
  }

  // segment + point number
  if(segments.length>1) s = inject(L.segpt,s0) + ' ' + inject(L.point,s1) ; 
  else s = caps(inject(L.point,s1)) ;
  d.appendChild(genspan(s,'hr','font-size:80%')) ;

  // edit functions (delete, drag, transfer... )
  if(segments[s0].pts.length>1) 
    d.appendChild(genclickfn(wpdel,caps(L.wpdel),'br')) ; 
  else d.appendChild(genspan(caps(L.wpdel),'br','color:silver')) ;
  d.appendChild(genclickfn(draggit,L.mkdrag,'br')) ; 
  function wpfactory(ind) { return function() { inswp(ind) ; } ; } ;
  d.appendChild(genclickfn(wpfactory(1),inject(L.dragaway,L.dragfor),'br')) ; 
  d.appendChild(genclickfn(wpfactory(-1),inject(L.dragaway,L.dragback),'br')) ;

  if((s1==0&&s0>0)||(s1==segments[s0].pts.length-1&&s0<segments.length-1))
    if(segments[s0].pts.length>1)
      d.appendChild(genclickfn(xferwp,inject(L.xferwpt,s1==0?s0-1:s0+1),
        'br','font-size:80%')) ;
  return d ; 
}
/* -------------------------------------------------------------------------- */

function titlediv(opt,val,editable,segno)
{ var s,d=document.createElement('div'),i,maxl,len,origin,f ; 
  if(segno==undefined) segno = null ; 
  function titlefactory(p1,p2) { return function() { retitle(p1,p2) ; } ; } ;

  if((editable||opt=='desc')&&!val)
  { domadd(d,'[') ;
    if(opt=='desc') s = L.adddesc ; else if(opt=='title') s = L.addtitle ;
    d.appendChild(genclickfn(titlefactory(opt,segno),s)) ;
    domadd(d,']') ;
    return d ; 
  }

  if(opt=='title') d.appendChild(domcreate('b',L.title+': '+val)) ; 
  else if(opt=='desc') 
  { if(segno==null) origin = routeprops.origin ;
    else origin = segments[segno].origin ;
    if(editable) f = titlefactory('desc',segno) ; else f = null ;
    maxl = parsedesc(d,'<b>'+L.desc+':</b> '+val,f,origin) ; 
  }
  else
  { if(opt=='source') s = L.source ;
    else alert(inject2(L.illegalopt,opt,'titlediv')) ; 
    d.appendChild(domcreate('b',s+': ')) ;
    domadd(d,val) ; 
  }

  if(opt!='desc'||maxl<40) d.setAttribute('style','white-space:nowrap') ;

  if(editable&&opt!='desc')
  { s = domcreate('span','[','style','margin-left:1ex') ; 
    s.appendChild(genclickfn(titlefactory(opt,segno),L.edit)) ;
    domadd(s,']') ; 
    d.appendChild(s) ;
  }
  return d ;
}
/* -------------------------------------------------------------------------- */

var kup=null,kdown=null ; 
function textpromptcleanup()
{ partialcleanup() ; 
  if(kup) document.onkeyup = kup ; 
  if(kdown) document.onkeydown = kdown ;
  kup = kdown = null ;
}
function speaklang(lang)
{ if(prefs.lang!=lang)
  { var script = document.createElement('script') ; 
    script.src = 'resources/routemaster.' + lang + '.js' ;
    document.head.appendChild(script) ;
  }
  prefs.lang = lang ;
}
// it would be better to provide a separate function for turn instructions

function textprompt(msg,oldval,action,prompttype,oldlab)
{ var nlines,maxw,wid=0,w,h,l,t,defw,defh,labelno,temp,i,box,s,img ; 
  var div,bigdiv,ip,span,button,style,svg,labicon,lsvg,thislab,labname,c,d,i ;
  var ctx , cty , bdiv=(mapparent?mapparent:body) , detail,auto,precdiv ;
  var canvasdiv , lang , langs=['en','fr'] , flagdivs = [0,0] ;
  var tab,tr,td,lsty,email,cstyle,delopt=null ; 
  var nlabel = icons.names.length , btnlist = new Array(nlabel) ;
  var unflag = 'data:image/gif;base64,R0lGODlhJAAYAKIAAEGP3jqL3T6O3lec4m2\n' +
               'p5pK/7LnW8/X5/SwAAAAAJAAYAAADqgi63P4wykmrvUvojaVWQ0iEihB\n' +
               '0TDAUQDgQI1kMJxoQRhG/Y/4ahNpFMDAAeUjD4WcYCDCBgk9EJRRY0oK\n' +
               'wEj2KckbdVzsESGEw5fVw0KVnz4txhMsqDzGcARC3nO9oai56fENZVgd\n' +
               '7d15Se1B6I2JSiXRzWxRPjTs/lUaFUEVzLgMtek2XFipGOVetq0EoCyq\n' +
               'NQKYzqB1PIgEqJH2xGRo1vB/AxsfIyRIJADs=' ;
  var units = ['m','dm','cm','mm'] , unitdivs = [0,0,0,0] , precision ;
  kup = document.onkeyup ; 
  kdown = document.onkeydown ;
  document.onkeyup = document.onkeydown = null ; 
  if(!oldlab) oldlab = 0 ; 
  thislab = oldlab ; 

  function drawicon(iconno,scale,shift)
  { var icon = document.createElementNS(svgns,"path"),trans,x,y ; 
    if(!scale) scale = 1 ; 
    if(!shift) shift = 0 ; 
    x = ( 11 - icons.list[iconno].xmid ) * scale + shift;
    y = ( 22 - icons.list[iconno].anchor.y ) * scale + shift ;
    trans = 'matrix(' + scale.toFixed(1) + ',0,0,' + scale.toFixed(1) +
            ',' + x.toFixed(1) + ',' + y.toFixed(1) + ')' ;
    icon.setAttributeNS(null,'d',icons.list[iconno].path) ; 
    icon.setAttributeNS(null,'stroke','black') ; 
    icon.setAttributeNS(null,'stroke-width',1) ; 
    icon.setAttributeNS(null,'fill','gray') ; 
    icon.setAttributeNS(null,'transform',trans) ; 
    return icon ;
  }
  // generate buttons for labels
  function drawlabel(labelno,chosen)
  { var rect ; 
    function genrect(offset)
    { var rect = document.createElementNS(svgns,"rect") ; 
      rect.setAttributeNS(null,'x',offset) ; 
      rect.setAttributeNS(null,'y',offset) ; 
      rect.setAttributeNS(null,'width',36) ; 
      rect.setAttributeNS(null,'height',36) ; 
      rect.setAttributeNS(null,'rx',4) ; 
      rect.setAttributeNS(null,'ry',4) ;
      return rect ;
    }
    svg = document.createElementNS(svgns,"svg") ;
    svg.setAttributeNS(null,'height',39) ; 
    svg.setAttributeNS(null,'width',39) ; 

    // draw the button shading
    rect = genrect('2.5') ; 
    rect.setAttributeNS(null,'style','stroke:lightgray;stroke-width:1') ;
    svg.appendChild(rect) ; 

    // draw the button outline
    rect = genrect('0.5') ; 
    if(chosen) rect.setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:#e2e2e2') ;
    else rect.setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:white') ;
    svg.appendChild(rect) ; 
    svg.appendChild(drawicon(labelno,1.5,2)) ; 

    return [ svg , rect ] ; 
  }
  function iconsel(labelno)
  { if(labelno==thislab) return ; 
    while(labname.childNodes.length) 
      labname.removeChild(labname.childNodes[0]) ; 
    domadd(labname,L.labels[labelno]) ;
    temp = drawicon(labelno) ;
    lsvg.replaceChild(temp,labicon) ;
    btnlist[thislab].setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:white') ;
    btnlist[labelno].setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:#e2e2e2') ;
    labicon = temp ; 
    thislab = labelno ;
    ip.focus() ; 
    ip.setSelectionRange(ip.value.length,ip.value.length) ; 
  }
  function iconfactory(labelno) { return function() { iconsel(labelno) ; } }

  if(prompttype=='label') { defw = 41*8-2 ; defh = 132+45 ; }
  else if(prompttype=='desc') { defw = 500 ; defh = 240 ; }
  else if(prompttype=='loadwait') { defw = 320 ; defh = 20 ; }
  else if(prompttype=='optim') { defw = 100 ; defh = 60 ; }
  else if(prompttype=='prefs') { defw = 360 ; defh = 160 ; }

  bigdiv = document.createElement('div') ;
  bigdiv.setAttribute('style','position:absolute;width:100%;height:100%;'+
        'top:0;left:0;background:black;opacity:0.5') ;
  bdiv.appendChild(bigdiv) ; 
  w = Math.min(defw,window.innerWidth) ;
  h = Math.min(defh,window.innerHeight) ;
  l = Math.floor((window.innerWidth-w)/2) ;
  t = Math.floor((window.innerHeight-h)/2) ;

  div = domcreate('div',msg) ; 
  // margin:auto doesn't have any effect unless the width/height are specified,
  // and it's best not to specify them because they depend on text spacing. yuk!
  div.setAttribute('style','position:absolute;background:white;left:'+l+'px;'+
    'top:'+t+'px;border:2px black solid;padding:4px;font-family:helvetica;'+
    'margin:auto') ; 

  div.setAttribute('id','routemaster:div') ; 
  bigdiv.setAttribute('id','routemaster:bigdiv') ; 
  if(prompttype=='loadwait')
  { bdiv.appendChild(div) ; infowindow.closer = textpromptcleanup ; return ; }

  div.appendChild(document.createElement('br')) ; 

  if(prompttype=='label')
  { button = document.createElement('div') ;
    button.setAttribute('style',
                       'width:22px;height:22px;display:inline-block') ; 
    lsvg = document.createElementNS(svgns,"svg") ;
    lsvg.setAttributeNS(null,'height',22) ; 
    lsvg.setAttributeNS(null,'width',22) ; 
    labicon = drawicon(oldlab) ; // oldlab is index in list
    lsvg.appendChild(labicon) ; 
    button.appendChild(lsvg) ; 
    div.appendChild(button) ; 

    ip = domcreate('input',null,'value',oldval) ; 
    ip.setAttribute('style','font-family:helvetica;width:140px;'+
                          'height:24px;margin-bottom:6px;vertical-align:top') ;
    div.appendChild(ip) ; 

    labname = domcreate('span',L.labels[oldlab]) ;
    labname.setAttribute('style','padding-left:4px') ; 
    div.appendChild(labname) ; 

    for(labelno=0;labelno<nlabel;labelno++)
    { if(labelno==0||labelno==8) div.appendChild(document.createElement('br')) ;
      button = domcreate('div',null,'title',L.labels[labelno]) ;
      button.setAttribute('style','width:39px;height:45px;display:inline-block'+
           ((labelno==0||labelno==8)?'':';margin-left:2px')) ; 
      temp = drawlabel(labelno,labelno==oldlab) ;
      btnlist[labelno] = temp[1] ;
      button.appendChild(temp[0]) ; 
      button.onclick = iconfactory(labelno) ; 
      div.appendChild(button) ; 
    }
  }
  else if(prompttype=='optim')
  { box = domcreate('input',null,'type','checkbox') ; 
    box.setAttribute('id','savepref') ; 
    box.setAttribute('value','yes') ; 
    ip = domcreate('div',box) ; 

    ip.appendChild(domcreate('label',L.savepref,'for','savepref')) ; 
  }
  /* ------------------------------------------------------------------------ */

  else if(prompttype=='prefs') 
  { ip = domcreate('div') ;

    // email address
    email = domcreate('input',null,'type','email') ; 
    email.setAttribute('value',prefs.email) ; 
    email.setAttribute('style','width:350px') ; 
    ip.appendChild(domcreate('div',email)) ; 

    lsty = 'margin:auto;border-bottom:1px solid silver;padding-bottom:2px;' +
           'margin-bottom:4px' ;
    tab = domcreate('table',null,'style',lsty) ; 
    tab.setAttribute('cellpadding',0) ; 
    tab.setAttribute('cellspacing',0) ; 
    lsty = 'text-align:right;padding:2px 12px 2px 0' ;

    // 'Language'
    tr = domcreate('tr',domcreate('td',L.language,'style',lsty)) ; 

    // flags
    if(prefs&&prefs.lang) lang = langs.indexOf(prefs.lang) ; 
    else lang = 0 ; 
    if(lang<0) lang = 0 ;
    canvasdiv = domcreate('div',null,'style','position:relative') ;

    function flagcanvas(i)
    { var c = domcreate('canvas',null,'title',langs[i]) ;
      var csty = "width:18px;height:12px;cursor:pointer;border:2px solid " ;
      if(i==lang) csty += "grey" ; else csty += "white" ;
      c.width = 36 ; 
      c.height = 24 ; 
      c.setAttribute('style',csty) ; 
      c.onclick = function() { cclick(i) ; }
      return flagdivs[i] = c ; 
    }

    // english
    c = flagcanvas(0) ; 
    ctx = c.getContext("2d") ;
    img = new Image(36,24) ; 
    img.onload = function() { ctx.drawImage(img,0,0) ; } ;
    img.src = unflag ;
    canvasdiv.appendChild(c) ; 

    // french
    c = flagcanvas(1) ; 
    cty = c.getContext("2d") ;
    cty.strokeWidth = 0 ;

    for(i=0;i<3;i++) // tricolore 
    { cty.fillStyle = ["#0055A4","white","#EF4135"][i] ; 
      cty.beginPath() ; 
      cty.rect(12*i,0,12,24) ; 
      cty.fill() ;
    }
    canvasdiv.appendChild(c) ; 

    function cclick(langno)
    { if(langno==lang) return ; 
      flagdivs[lang].style.borderColor = 'white' ;
      lang = langno ;
      flagdivs[lang].style.borderColor = 'grey' ;
    }      
    tr.appendChild(domcreate('td',canvasdiv)) ; 
    tab.appendChild(tr) ; 

    // optimisation options: detail
    td = domcreate('td',null,'style',lsty) ; 
    td.appendChild(domcreate('label',L.detail+':','for','detail')) ; 
    tr = domcreate('tr',td) ; 

    detail = domcreate('input',null,'type','number') ; 
    detail.setAttribute('min','1') ; 
    detail.setAttribute('max','100') ; 
    detail.setAttribute('id','detail') ; 
    detail.setAttribute('value',prefs&&prefs.detail?prefs.detail:70) ; 
    detail.setAttribute('style','width:52px') ; 
    tr.appendChild(domcreate('td',detail)) ; 
    tab.appendChild(tr) ; 

    // auto optimise?
    td = domcreate('td',null,'style',lsty) ; 
    td.appendChild(domcreate('label',L.optonload,'for','auto')) ; 
    tr = domcreate('tr',td) ; 

    auto = domcreate('input',null,'type','checkbox') ; 
    auto.setAttribute('id','auto') ; 
    if(!prefs||prefs.optim) auto.setAttribute('checked',true) ; 
    tr.appendChild(domcreate('td',auto)) ; 
    tab.appendChild(tr) ; 

    // limit separation?
    td = domcreate('td',null,'style',lsty) ; 
    td.appendChild(domcreate('label',L.limsep,'for','limsep')) ; 
    tr = domcreate('tr',td) ; 

    box = domcreate('input',null,'type','checkbox') ; 
    box.setAttribute('id','limsep') ; 
    if(!prefs||prefs.maxsep) box.setAttribute('checked',true) ; 
    tr.appendChild(domcreate('td',box)) ; 
    tab.appendChild(tr) ; 

    // track precision
    if(prefs.precision&&prefs.precision>0&&prefs.precision<units.length) 
      precision = prefs.precision ; 
    else precision = 0 ; 
    tr = domcreate('tr',domcreate('td',L.precision,'style',lsty)) ; 

    precdiv = domcreate('div',null,'style','position:relative;cursor:pointer') ; 

    // this would be simpler if each box had its own onclick
    for(i=0;i<units.length;i++) 
    { unitdivs[i] = s = domcreate('div',units[i]) ;
      s.setAttribute('style','display:inline-block;text-align:center;' +
                     'width:29px;left:'+(31*i)+'px;overflow:hidden;' + 
                     'border:2px solid') ; 
      s.style.borderColor = (i==precision?'grey':'white') ; 
      precdiv.appendChild(s) ;
    }
    function pclick(e)
    { var i,prec ; 
      if(e) i = e.clientX - precdiv.getBoundingClientRect().left ; else i = 0 ; 
      prec = Math.floor(i/31) ;
      if(prec==precision) return ; 
      unitdivs[precision].style.borderColor = 'white' ; 
      precision = prec ; 
      unitdivs[precision].style.borderColor = 'grey' ; 
    }      
    precdiv.onclick = pclick ;
    tr.appendChild(domcreate('td',precdiv)) ; 
    tab.appendChild(tr) ; 
    ip.appendChild(tab) ; 
  }
  else 
  { style = 'font-family:helvetica;width:'+(w-8)+'px;height:'+(h-36)+
            'px;font-size:110%' ;
    ip = domcreate('textarea',oldval?oldval:null,'style',style) ;
  }

  // handle submit/return key
  function returnkey()
  { textpromptcleanup() ; 
    if(prompttype=='optim') 
    { if(box.checked)
      { prefs.detail = oldval[0] ; 
        prefs.maxsep = oldval[1] ; 
        var xhttp = new XMLHttpRequest() ;
        xhttp.onreadystatechange = function() 
        { if(xhttp.readyState==4) 
          { if(xhttp.response.length==0) alert(L.dberr) ; 
            else if(xhttp.response.substring(0,9)=="**sqlerr:") 
              alert(xhttp.response) ;
          }
        }
        xhttp.open("GET","resources/signup.php?opts="+
                                     prefs.detail+'&'+prefs.maxsep) ;
        xhttp.send() ;
      }
      action(box.checked) ; 
    }
    else if(prompttype=='prefs') 
    { var xhttp = new XMLHttpRequest() ;
      xhttp.onreadystatechange = function() 
      { if(xhttp.readyState==4) 
        { if(xhttp.response.length==0) alert(L.dberr) ; 
          else if(xhttp.response=="**sqlerr: bademail") alert(L.bademail) ; 
          else if(xhttp.response=="**sqlerr: already") 
            alert(inject(L.already,email.value)) ; 
          else if(xhttp.response.substring(0,9)=="**sqlerr:") 
            alert(xhttp.response) ;
          else prefs.email = email.value ; 
        }
      }
      speaklang(langs[lang]) ; 
      prefs.detail = detail.value ;
      prefs.optim = auto.checked?1:0 ;
      prefs.maxsep = box.checked?100:0 ;
      prefs.precision = precision ;
      xhttp.open("GET","resources/signup.php?opts="+email.value+'&'+
                 prefs.lang+'&'+prefs.detail+'&'+prefs.optim+'&'+
                 prefs.maxsep+'&'+prefs.precision) ;
      xhttp.send() ;
    }
    else action(delopt?null:ip.value,thislab) ; 
  }
  
  if(prompttype!='desc') ip.onkeydown = function(e)
  { if(e.keyCode==13||e.which==13) { e.preventDefault() ; returnkey() ; } }

  if(prompttype!='label') div.appendChild(ip) ; 

  // cancel
  button = domcreate('input',null,'type','button') ; 
  button.setAttribute('value',caps(L.cancel)) ; 
  button.setAttribute('style','background-color:white') ; 
  button.onclick = function() 
  { textpromptcleanup() ; if(prompttype=='optim') action('cancel') ; }
  span = domcreate('span',button) ; 

  // default
  if(prompttype=='prefs')
  { button = domcreate('input',null,'type','button') ; 
    button.setAttribute('value',L.defaults) ; 
    button.setAttribute('style','background-color:white;margin-left:4px') ; 
    button.onclick = function() 
    { auto.checked = defprefs.optim ; 
      box.checked = defprefs.maxsep ; 
      pclick() ; 
      detail.value = defprefs.detail ; 
    }
    span.appendChild(button) ; 
  }

  // delete
  if(prompttype=='label'&&oldlab)
  { button = domcreate('input',null,'type','button') ; 
    button.setAttribute('value',caps(L.del)) ; 
    button.setAttribute('style','background-color:white;margin-left:4px') ; 
    button.onclick = function() { delopt = 1 ; returnkey() ; }
    span.appendChild(button) ; 
  }

  // submit
  button = domcreate('input',null,'type','button') ; 
  button.setAttribute('value',L.ok) ; 
  button.setAttribute('style','background-color:#e2e2e2;margin-left:4px') ; 
  button.onclick = returnkey ;
  span.appendChild(button) ; 
  span.setAttribute('style','margin:auto;display:table') ; 
  div.appendChild(span) ; 

  // doc link
  if(prompttype=='label') div.appendChild(doclink('labels',L.labeldoc)) ; 
  else if(prompttype=='desc') div.appendChild(doclink('desc',L.descdoc)) ; 
  bdiv.appendChild(div) ; 
  if(prompttype=='label')
  { ip.focus() ; ip.setSelectionRange(0,oldval.length) ; }
}
/* -------------------------------------------------------------------------- */

function genldiv(p)
{ var tdiv = document.createElement('div') ;
  function tdivadd(a,s)
  { var ldiv = document.createElement('div') , i ;
    for(i=0;i<5;i++) 
    { domadd(ldiv,a[i]); ldiv.appendChild(document.createElement('br')) ; }
    ldiv.setAttribute('style','float:left;'+s) ; 
    tdiv.appendChild(ldiv) ; 
  }

  tdivadd(L.tabvals,'padding-right:8px') ;
  function intise(x)
  { if(x==null||x==undefined) return '–' ; else return x.toFixed(0) ; }
  tdivadd([(p.dist/1000).toFixed(3),intise(p.asc),intise(p.desc),
          intise(p.maxalt),intise(p.minalt)],'text-align:right') ;
  tdivadd(['km','m','m','m','m'],'padding-left:2px') ;
  return tdiv ; 
}
/* -------------------------------------------------------------------------- */

function starsline(nstars,editable,d)
{ var i,c,s,d ;
  if(!d) d = document.createElement('div') ; 
  else if(editable) while(d.childNodes.length>0) 
    d.removeChild(d.childNodes[d.childNodes.length-1]) ;
 
  for(i=1;i<=5;i++) 
  { if(nstars==null||i>nstars) c = '\u2606' ; else c = '\u2605' ;
    s = domcreate('span',c) ; 
    if(editable&&i!=nstars) 
    { s.setAttribute('style','cursor:pointer') ; 
      function createfunc(i) { return function() { restars(nstars,i,d) ; } } ;
      s.onclick = createfunc(i) ; 
    }
    d.appendChild(s) ; 
  }
  if(editable&&nstars!=null) 
  { d.appendChild(genspan(' [')) ; 
    function starfactory(n) { return function() { restars(n,null,d) ; } ; } ;
    d.appendChild(genclickfn(starfactory(nstars),L.clearword)) ; 
    d.appendChild(genspan(']')) ; 
  }
  return d ; 
}
/* -------------------------------------------------------------------------- */
/*          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) 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 = domcreate('div',null,'id','prodiv') ;
  this.curdiv = domcreate('div',null,'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 ;
  }
}
/* -------------------------------------------------------------------------- */

function drawxcur(pro,sel)
{ var pos , i , linewid , inc , cx, cy , jlim , div , r=pro.radius , w , h ; 
/*
  if(sel[0]<0||sel[0]>=segments.length) // diagnostix
    console.log('s0='+sel[0]+'/'+segments.length) ; 
  else if(sel[1]<0||sel[1]>=segments[sel[0]].pts.length) 
    console.log('s1='+sel[1]+'/'+segments[sel[0]].pts.length) ; 
  else if( pro.m.wp2pro[sel[0]][sel[1]]==null
        || pro.m.wp2pro[sel[0]][sel[1]]==undefined ) 
    console.log(pro.m.wp2pro[sel[0]][sel[1]]) ;
*/
  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) { w = 620 ; h = 200 ; } else w = h = 42 ; 
  c.setAttribute('width',w*pro.ratio) ; 
  c.setAttribute('height',h*pro.ratio) ; 
  c.setAttribute('style','width:'+w+'px;height:'+h+'px') ; 
  div.appendChild(c) ;
  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() ; 
}
/* -------------------------------------------------------------------------- */

var pcopy,scopy ;

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

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

  pro.m = new profilemaptype(segments,maxi) ; 
  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.8 ; 
    ctx.fillStyle = '#f0f0f0' ;
    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 
  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(h===undefined&&pro.active) continue ; 
    if(h===null)
    { if(i>0&&pro.m.h[i-1]!==null) 
      { for(j=i-1;j>=0&&(pro.m.h[j]===null||pro.m.h[j]===undefined);j--) ; 
        if(j>=0) defh = pro.m.h[j] ; else defh = null ;
        for(j=i+1;j<maxi&&(pro.m.h[j]===null||pro.m.h[j]===undefined);j++) ; 
        if(j<maxi) 
        { if(defh===null) defh = pro.m.h[j] ;
          else defh = ( pro.m.h[j] + defh ) / 2 ; 
        } // defh is the altitude corresponding to the horizontal line
        defh = (defh+pro.m.hmin)/2 ; 
      }
      hind = Math.floor(maxj*(pro.m.hmax-defh)/pro.m.hspan) ;
    }
    else if(h!==undefined)
    { h = maxj*(pro.m.hmax-h)/pro.m.hspan ; hind = Math.floor(h) ; }

    segno = pro.m.pro2wp[i][0] ;
    col = dehexify(segments[segno].colour) ; 

    if(h===null&&pro.active) for(j=hind-1;j<hind+1&&j<pro.innerh;j++) 
    { if(j>=0)
      { ind = 4 * (i+maxi*j) ; 
        for(k=0;k<3;k++) d[ind+k] = col[k] ; 
        d[ind+3] = 255 ; 
      }
    }
    else if(pro.active) for(j=hind;j<pro.innerh;j++)
    { ind = 4 * (i+maxi*j) ; 
      for(k=0;k<3;k++) d[ind+k] = col[k] ; 
      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))) ;
      j = r - jlim ; 
      if(j!=Math.floor(j)) j += 1 ; 
      for(;(h==null||h==undefined||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) ; 
        if(j==hind) 
        { k = Math.floor(0.5+(h-hind)*255+(hind+1-h)*k) ;
          for(k=0;k<4;k++) d[ind+k] = k ; 
        }
        else { for(k=0;k<3;k++) d[ind+k] = col[k] ; d[ind+3] = 255 ; }
      }
      if(h===null) for(j=hind-1;j<hind+1;j++) if(j>=r-jlim&&j<r+jlim)
      { ind = 4 * (i+maxi*j) ; 
        for(k=0;k<3;k++) d[ind+k] = col[k] ;
        d[ind+3] = 255 ; 
      }
    }
  }
  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) ;
    }

    if(pro.m.d>50000) { step = 10000 ; inc = 5 ; }
    else if(pro.m.d>25000) { step = 5000 ; inc = 2 ; }
    else if(pro.m.d>5000) { step = 1000 ; inc = 5 ; }
    else if(pro.m.d>2500) { step = 500 ; inc = 2 ; }
    else { step = 100 ; inc = 10 ; }

    for(i=1;i*step<pro.m.d;i++) 
    { x = pro.offs + pro.innerw*i*step/pro.m.d ;
      if(linewid&1) x = 0.5 + Math.floor(x) ; // nearest half-integer
      else x = Math.floor(0.5+x) ; 
      ctx.beginPath() ; 
      ctx.lineWidth = linewid ; 
      ctx.strokeStyle = '#555' ; 
      ctx.moveTo(x,pro.offs+pro.innerh) ;
      ctx.lineTo(x,pro.offs+0.95*pro.innerh) ; 
      ctx.stroke() ; 
      if(i%inc==0) ctx.strokeText((i*step)/1000,x-8,pro.offs+0.93*pro.innerh) ;
    }
  }

  // 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) 
  { 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) ;
}
/* ----------------------------- file dialogue ------------------------------ */

function filedialogue(ovr)
{ var n , b , list , hits , hit , opt , div = document.createElement('div') ; 
  var input , ind , prose , listuri , d , para = document.createElement('p') ;
  var span,filebox ; 
  para.setAttribute('style','font-family:helvetica;margin:0') ;

  var lab = domcreate('label',null,'for','filedialogue') ; 

  // file browser
  if(ovr=="list") domadd(lab,L.loadlist+': ') ;
  else 
  { domadd(para,L.select) ;
    input = domcreate('input',null,'type','file') ; 
    input.setAttribute('accept','.tcx,.gpx,.kml,.fit,.rte,.csv') ; 
    input.setAttribute('multiple','multiple') ; 
    input.addEventListener('change',function(e)
    { infowindow.close() ; 
      var filename , fileno , readers = new Array(input.files.length) ; 
      render.overwrite = ovr ;
      if(ovr=='load') zonktale() ; 
      // the first file loaded (not nec. fileno 0) will call 'render' with 
      // render.overwrite equal to ovr, but render resets render.overwrite to 
      // 'add' allowing subsequent files to be added 
      for(fileno=0;fileno<input.files.length;fileno++)
      { readers[fileno] = new FileReader() ;
        filename = input.files[fileno].name ;
        function readerfactory(i) 
        { return function(e)
          { render(readers[i].result,input.files[i].name,'file') ; } 
        }
        readers[fileno].onload = readerfactory(fileno) ; 
        if(filename.substring(filename.length-4).toLowerCase()=='.fit')
          readers[fileno].readAsArrayBuffer(input.files[fileno]) ;	
        else readers[fileno].readAsText(input.files[fileno]) ;	
      }
    } ) ;
    para.appendChild(input) ; 
    para.appendChild(domcreate('span',L.compfit,'style','font-size:80%')) ; 
    div.appendChild(para) ; 

    para = domcreate('p',L.orweb+': ') ;
    para.setAttribute('style','font-family:helvetica;margin:4 0 0') ; 
  }
  para.appendChild(lab) ; 

  // url input box
  filebox = domcreate('input',null,'id','filedialogue') ; 
  if(ovr=='list'&&imginfo.status=='?') 
    filebox.setAttribute('value',imginfo.uri) ; 
  else 
  { if(ovr=="list") hits = prefs.pixhits ; else hits = prefs.gpshits ; 
    while(hits.length&&!hits[hits.length-1]) hits.length -= 1 ; 
    list = domcreate('datalist',null,'id','dlist') ; 
    if(hits) for(i=-1;i<hits.length;i++)
    { if(i<0) 
      { if(ovr=='list') hit = pixerr ; else hit = gpserr ; if(!hit) continue ; }
      opt = document.createElement('option') ; 
      opt.value = i<0?hit:hits[i] ; 
      list.append(opt) ; 
    }
    filebox.appendChild(list) ; 
    filebox.setAttribute('list','dlist') ;
  }
  filebox.setAttribute('style','width:500px;height:24px') ;
  filebox.onkeyup = function(e)
  { var s ; 
    if(e.keyCode==13||e.which==13) 
    { infowindow.close() ; 
      if(ovr=='list') 
      { if(imginfo.status=='?'&&this.value==imginfo.uri) 
        { imginfo.status = 'ready' ; photoprompt() ; return ; }
        else listuri = rellist(this.value) ; 
      }
      else trackuri = this.value ; 
      if(ovr!='list')
      { s = trackuri.substring(trackuri.length-4).toLowerCase() ;
        if(trackextns.indexOf(s)>=0) s = shortenname(trackuri) ;
        else if( trackuri.indexOf('google')>=0 
              && trackuri.indexOf('/maps/dir/')>=0 ) s = L.gmaps[0] ;
        else s = L.gmaps[1] ;
        textprompt(inject(L.waitingfor,s),null,null,'loadwait') ; 
      }
      if(ovr=='list') { greyout(photobtn) ; getlist(listuri,'uriform') ; } 
      else 
      { readuri(function(r) { render(r,trackuri,'uriform',null,ovr) ; },1) ; }
    }
  } ;
  para.appendChild(filebox) ; 

  if(ovr=='list') 
  { underline(para) ; 
    div.appendChild(para) ; 
    para = domcreate('p',null,'style','font-family:helvetica;margin:0') ;
    para.appendChild(genlink('https://www.masterlyinactivity.com/software/' + 
                             'routemaster.html#photos',L.photodoc,1)) ; 
    div.appendChild(para) ; 
  }
  else
  { div.appendChild(para) ; 
    d = domcreate('div',genspan(L.urldoc,null,'font-size:80%')) ;
    d.setAttribute('style','font-family:helvetica;padding-top:4px') ; 
    div.appendChild(d) ; 
  }
  return div ;
}
/* -------------------------------------------------------------------------- */

function telltale(msg) 
{ zonktale() ; 
  var sty = 'background:white;position:absolute;'+
            'left:0;bottom:0;padding:3px;z-index:2' ; 
  telltale.div = domcreate('div',msg,'style',sty) ;
  telltale.opacity = 10 ; 
  telltale.div.setAttribute('style','background:white;position:absolute;'+
                                    'left:0;bottom:0;padding:3px;z-index:2') ; 
  body.appendChild(telltale.div) ; 
  setTimeout(wipetale,3000) ; 
}
function wipetale()
{ if(!telltale.div) return ; 
  telltale.opacity -= 1 ; 
  if(telltale.opacity<=0) zonktale() ; 
  else 
  { telltale.div.style.opacity = telltale.opacity/10 ; 
    setTimeout(wipetale,100) ; 
  }
}
function zonktale()
{ if(telltale.div) telltale.div.parentNode.removeChild(telltale.div) ; 
  telltale.div = null ; 
}

var L=
{ // routemaster
  unsavedchanges:['1 unsaved change','% unsaved changes'] , 
  ifyouhit:'If you hit' ,
  ok:'OK' , 
  leavepage:'Leave page' , 
  willbelost:['this change will be lost.','these changes will be lost.'] ,
  isnotxml:'is not an XML file' , 
  unable:'Unable to read %' , 
  inconsistentlists:'Inconsistent photo lists:' , 
  untitledroute:'Untitled route' , 
  controlmenu:'Control menu' , 
  routeprops:'Route properties' , 
  segmentprops:'Segment properties' , 
  waypointprops:'Waypoint properties' , 
  atwaypoint:'at current waypoint' , 
  nosplitsegment:'[disabled at first waypoint in a segment]' , 
  labelwaypoint:'Label selected waypoint' ,
  addaphoto:'Add a photo' , 
  noaddaphoto:'[disabled while waiting for photo list]' , 
  undolatest:'Undo latest edit' , 
  noundolatest:'Undo [no edits performed]' , 
  redolatest:'Redo latest undone edit' , 
  noredolatest:'Redo [no edits undone]' , 
  saveroute:'Save route' , 
  nosaveroute:'Save [disabled until segments are combined]' , 
  account:'Account' , 
  register:['Create an account','Login','Forgot password?','Change password'] ,
  otkey:'Your confirmation key has been emailed to you (check your spam folder).' ,
  illegalopt:'Illegal option %0 for %1' ,
  waitingfor:'Waiting for %' , 
  aphotolist:'a photo list... try again later' ,
  youneedtocombine:'You need to combine segments before saving.' , 
  untitled:'Untitled' , 
  saveindexas:'Save %index as' , 
  saveXas:'Save % as' , 
  saveas:'Save as' , 
  interpextra:'interpolate extra points' , 
  yourtimes:'Note: your times are out of sequence and\nwill not be retained' ,
  missingalts:'missing altitudes from Google Elevation Service' ,
  dontwanttowait:'If you don’t want to wait you can' ,
  cancel:'cancel' , 
  del:'delete' , 
  defaults:'Defaults' ,
  useinterp:'use interpolated altitudes' ,
  savemissing:'or save with missing altitudes' ,

  addtitle:'Add title' ,
  adddesc:'Add description' ,
  addinfo:'Add info' ,
  editindex:'Modify index title' ,
  enteroffset:'Enter offset in metres to add to altitudes:' ,
  isnan:'% is not a number' ,
  enteralt:'Enter altitude (m):' ,
  enterlabel:'Enter label:' ,
  modlabel:'Modify or delete label:' ,
  enterphoto:'Enter photo name:' ,
  newphoto:'New photo name:' ,
  exitfs:'Exit full screen [esc key]' ,
  enterfs:'Enter full screen [f key]' ,
  notes:'notes' ,
  gpstrack:'GPS track' ,
  cantundoload:'Unable to undo load while waiting for %' ,
  deletesegment:'delete segment' ,
  deleteroute:'delete route' ,
  deleteindex:'delete index' ,
  splitsegment:'split segment' ,
  xferwaypoint:'transfer waypoint' ,
  dellabel:'delete label' ,
  labelpt:'label waypoint' ,
  editlabel:'edit label' ,
  removelabels:'remove labels' ,
  edittitle:'edit title' ,
  editdesc:'edit description' ,
  editxdesc:'edit ‘%’ description' ,
  editinfo:'edit url of info page' ,
  wpdel:'delete waypoint' ,
  wpins:'insert waypoint' ,
  wpdrag:'drag waypoint' ,
  recalalts:'manual recalibration of altitudes' ,
  googlelats:'replace altitudes by Google estimates' ,
  regressalts:'Google regression of altitudes' ,
  calibalts:'Google additive calibration of altitudes' ,
  wpalt:'set altitude' ,
  wpicon:'change icon' ,
  combinesegments:'combine % segments' ,
  revsegment:'reverse segment' ,
  dupsegment:'duplicate segment' ,
  optimsth:'optimise %' ,
  optim:'optimise' ,
  refresh:'update' ,
  deltimes:'delete segment times' ,
  delalts:'delete segment altitudes' ,
  delphoto:'delete photo' ,
  addphoto:'add photo' ,
  modphoto:'change photo' ,
  clear:'clear %' ,
  set:'set %' ,
  swapseg:'swap segments' ,
  loadsth:'load %' ,
  load:'load' ,
  addsth:'add %' ,
  unrecogaction:'Unrecognised action: %' ,
  undo:'Undo %' ,
  redo:'Redo %' ,

  slightl:'bear L' , 
  sharpl:'sharp L' , 
  uturnl:'u-turn L' , 
  turnl:'turn L' , 
  rampl:'ramp L' , 
  forkl:'fork L' ,
  raboutl:'roundabout L' , 
  slightr:'bear R' , 
  sharpr:'sharp R' , 
  uturnr:'u-turn R' , 
  turnr:'turn R' , 
  rampr:'ramp R' , 
  forkr:'fork R' ,
  raboutr:'roundabout R' , 
  straight:'straight on' , 
  merge:'merge' ,
  notracks:'No tracks' ,
  googledirs:'Google directions' ,
  shortfit:'supposed FIT file is too short' ,
  unfitfit:'supposed FIT file has format designator ‘%’' ,
  missfit:'missing definition in FIT file' ,
  routes:'routes' ,
  numroutes: '% routes' ,
  alti:'altitude' ,
  cantmeta:'Unable to save index while waiting for photolist' ,

  emptyseg:'Empty segment' ,
  logicerr:'Logic error in %' ,
  goql:'No calibration data available: over Google query limit' ,
  ginv:'Invalid calibration request' ,
  gden:'Calibration request denied' ,
  gunk:'Unknown error reported for calibration request: %' ,
  gcer:'Calibration error' ,
  gcor:'elevation response does not correspond to request' ,
  aroute:'a route' , 
  anindex:'an index' , 
  ameta:'a metaindex' ,
  nodata:'No data returned' ,
  addto0:'Somehow you are adding a track to an empty set' ,
  addxtoy:'Adding %0 to %1' ,
  missingurialt:'Missing altitudes not supported in uri load' , 
  missingidxalt:'Missing altitudes not allowed for tracks added to an index' ,
  updmulti:'Trying to update a track from a multi-track file' ,
  photos:'photos' ,

  // routemasterui
  photolist:'photolist' ,
  listnotfound:'Photolist not found' ,
  title:'Title' ,
  indextitle:'Index title' ,
  desc:'Description' ,
  stats:'Stats' ,
  date:'Date' ,
  colour:'Colour' ,
  npoints:'%0 (%1 points)' ,
  delroute:['Delete route','Delete routes'] , 
  updroute:['Update route','Update routes'] , 
  updnotposs:'Update of file from disc not possible' ,
  updsub:'Update of subindex not implemented' ,
  track:['track','tracks'] ,
  emailprompt:'Enter your email address:' ,
  aloneprompt:'Enter your password:' ,
  pwdprompt:'Enter your %0 and %1:' ,
  email:'email address' ,
  pwd:'password' ,
  confpwd:'confirm password' ,
  otk:'one-time key' ,
  submit:'Submit' ,
  rmgps:'Routemaster GPS track editor' ,
  rmdisplays:'displays GPS tracks (including embedded photos),' +
     ' allowing them to be edited and saved to disc.' ,
  funcs:[ 'Load gpx, tcx, and fit tracks;' ,
          'Load tracks defined by Google Maps direction pages;' ,
          'Optimise the track (i.e. remove redundant waypoints);' ,
          'Add/delete/move points;' ,
          'Divide routes into segments;' ,
          'Delete/reorder/reverse segments;' ,
          'Combine routes;' ,
          'View and modify route/segment/waypoint properties;' ,

          'Add labelled course points (eg. ‘sharp R tn’);' ,
          'Add photos;' ,
          'Adjust altitudes manually or using the Google elevations service;' ,
          'View an altitude profile;' ,
          'Create a route index and metaindex (index of indexes);' ,
          'Visualise the route in full screen;' ,
          'Undo and redo editing operations;' ,
          'Save as tcx/gpx/fit.' ] ,
  setsave:'settings / save' ,
  unre:'undo/redo' ,
  logindoc:['register/login/prefs/logout:',
            'logging in allows routemaster to work better but is not essential'] ,
  propsdoc:['view and modify route/segment/waypoint properties',
            '(help menu is under route properties)'] ,
  scisdoc:['split the current segment at the selected point /',
           'label a waypoint / add a photo'] ,
  save:'save' ,
  delbksp:'[shift del], [shift backspace] = delete route.' ,
  wpmove:'move to the previous/next waypoint;' ,
  segmove:'move to the previous/next segment;' ,
  shift:'shift' ,
  centres:'centres the map on the current waypoint;' ,
  spacedoc:'[space] makes the current waypoint draggable or terminates dragging.' , 
  dragfor:'a draggable waypoint forwards' ,
  dragback:'a draggable waypoint backwards' ,
  tabdoc:'[tab] inserts %;' ,
  stabdoc:'[shift tab] inserts a draggable waypoint backwards;' ,
  deldoc:'[del], [backspace] = delete waypoint;' ,
  sdeldoc:'[shift del], [shift backspace] = delete segment.' ,
  mouse:'Mouse:' ,
  clickdoc:'[click] selects the waypoint closest to the cursor position;' ,
  sclickdoc:'[shift click] extends the current segment to the cursor position.' ,
  extrack:'Example track to experiment with' ,
  exindex:'Example of a route index' ,
  userman:'User manual' ,
  rmfns:'Routemaster functions:' ,
  rmtc:[ 'By using Routemaster you agree to be bound by ' ,
         'Google Maps terms of use' ] ,
  hitspace:'Hit [space] when you’ve finished dragging' ,
  addroute:'Add route' ,
  addindex:'Add index or route' ,
  savemeta:'Save index as metaindex' ,
  hidear:'Hide arrows' ,
  showar:'Show arrows' ,
  listroutes:'Save list of routes' ,
  help:'Help' ,
  viewidx:'View index' ,
  view:'View gallery' ,
  lastadded:'Last added route: ' ,
  noalts:['1 point has no associated altitude',
          '% points have no associated altitudes'] ,
  findalts:'Find altitudes' ,
  waitalts:'Awaiting results from elevation service' ,
  notimes:'No timings provided' ,
  untimed:['1 point has no timing','% points have no timings'] ,
  badtimes:'Times are out of sequence (and will be discarded on file save)' ,
  numlabels:['1 labelled waypoint','% labelled waypoints'] ,
  remove:'Remove' ,
  numphotos:['1 photo','% photos'] ,
  numsegments:['1 segment','% segments'] ,
  combine:'Combine' ,
  combnote:'Note that segments must be combined before saving' ,
  sepwarn:'Note that separations >100m may cause problems on Garmin' ,
  loadnewroute:'Load new route' ,
  loadnewseg:'add route as new segment' ,
  saveidx:'Save track as route index' ,
  segment:'Segment %0 of %1 (%2 points)' ,
  source:'Source' ,
  prevopt:'Previously optimised' ,
  optimised:'Optimised' ,
  optimise:'Optimise' ,
  optimdetail:'Optimised (detail=%)' ,
  swapwith:'Swap with%0(%1)' ,
  combinewith:'Combine with%0(%1)' ,
  preceding:'preceding' ,
  following:'following' ,
  nodeltimes:'Delete times (no times present)' ,
  askgoogle:'Ask Google to supply segment altitudes' ,
  adjalts:'Adjust altitudes...' ,
  regress:'Correct segment altitudes by linear regression' ,
  googleadd:'Correct segment altitudes by calibration' ,
  vsgoogle:'The regression and calibration are against Google estimates' ,
  repalts:'Replace segment altitudes by Google estimates' ,
  offalts:'Add a manual offset to segment altitudes' ,
  docalts:'Documentation of altitude options' ,
  edit:'Edit' ,
  photo:'Photo:' ,
  nolist:'no list provided' ,
  notpres:'not present in %' ,
  notavail:'% is not available' ,
  info:'Info' ,
  enlarge:'Enlarge' ,
  nsew:'NSEW' ,
  osref:'OS grid ref' ,
  utm:'UTM coords' ,
  time:'Time' ,
  flat:'Flat' ,
  climb:'Climb' ,
  descend:'Descend' ,
  kmh:'km/h' ,
  segpt:'Segment %' , 
  point:'point %' ,
  mkdrag:'Make waypoint draggable' ,
  dragaway:'Insert %' ,
  xferwpt:'Transfer waypoint to segment %' ,
  viewinfo:'View info page' ,
  editurl:'Edit url' ,
  addurl:'Add url of info page' ,
  labeldoc:'Documentation for waypoint labels' ,
  descdoc:'Documentation for route descriptions' ,
  accdoc:'Reasons for creating a account' ,
  tabvals:['Total distance:','Total ascent:','Total descent:',
           'Maximum altitude:','Minimum altitude:'] ,
  clearword:'Clear' ,
  loadlist:'Load photo list from the web' ,
  orweb:'or load from the web' ,
  gmaps:['Google maps','data'] ,
  photodoc:'Documentation for including photos' ,
  urldoc:'The URL may be of a TCX/GPX/KML/FIT file or of a '+
         'Google Maps page giving directions from one place to another.' ,

  // index.html
  nogoogle:'Unable to fetch Google Maps API.\n'+
    'This may be because the Google server is not responding,\n' + 
    'or because of a bug somewhere,\n' + 'or because ' +
    'your browser is confused (in which case restarting it may help).' ,
  loadrm:'Loading %...' ,
  failed:'Failed to read %: no data returned' ,
  notadir:'% is not a Google directions result' ,
  notenuff:'% has too few fields for a Google directions result' ,
  noviewbox:'** Error: % does not have a viewbox field' ,
  badend:'** Error: % does not end with a data field' ,
  nodata:'** Error: % has no data points' ,
  badtrack:'Invalid track URL (must be .gpx, .tcx, .rte, .kml or .fit)' ,
  badopt:'Unrecognised option: ' ,
  distance: 'Distance' ,
  altitude: 'altitude' ,

  // subsequent additions
  illegalfield:'Illegal % field' ,
  select:'Select TCX/GPX/KML/CSV/FIT file(s): ' , 
  compfit:' (FIT files are now fully supported)',
  sqlerr:'SQL error:' ,
  cookies:'Registering with routemaster indicates consent '+
           'to the use of cookies to store session data.'  , 
  already:'Email address % already registered' ,
  formerr:'Unable to communicate with server: error=' ,
  incorrect:'Incorrect password' ,
  badotkey:'Incorrect one-time key' ,
  expired:'One-time key expired' ,
  baduser:'% is not a registered user' ,
  toosoon:'There is already an unexpired one-time key for %' ,
  dberr:'Unable to connect to database:' ,
  bademail:'Illegal email address' , 
  logout:'Log out' ,
  delact:'and close account' ,
  bugreport:'Report a bug/request help' , 
  delwarn:'Close account?\nIf you proceed, all preferences will be lost.' ,
  prefs:'Update preferences' ,
  labels: [ 'Generic' , 'Sharp left' , 'Left' , 'Slight left' , 
            'Straight' , 'Slight right' , 'Right' , 'Sharp right' , 
            'Danger' , 'Food' , 'Water' , 'Summit' , 
            'Valley' , 'First Aid' , 'Info' , 'Obstacle' 
          ] ,
  points:'points' ,
  detail:'Optimisation detail' , 
  savepref:'Save as preference?' , 
  language:'Language:' ,
  maxsep:'Maximum waypoint separation: %m' ,
  limsep:'Limit point separation?' , 
  optonload:'Optimise on load?' ,
  precision:'GPS track precision:' , 
  prob:'routemaster problem' , 
  alreadygone:'You are already logged out' ,
  devdata:'.fit files containing developer fields not accepted' ,
  gapsfixed:'Additional waypoints have been interpolated.' ,
  formatdoc:'Documentation for track formats' ,
  cantplace:'Unable to place coursepoint: ' ,
  untimedrecord: 'Record encountered with no timestamp' ,
  untimedcoursept: 'Course point encountered with no timestamp' ,
  index: 'index' , 
  metaindex: 'metaindex' ,
  nameforX: 'Enter name for % (without extension)' ,
  view1: 'View'
} ;

var L=
{ // routemaster
  unsavedchanges:['1 modification non enregistrée',
                  '% modifications non enregistrées'] , 
  ifyouhit:'Si vous poussez' ,
  ok:'OK' , 
  leavepage:'Quitter la page' , 
  willbelost:['cette modification sera perdu.',
              'ces modifications seront perdues.'] ,
  isnotxml:'n’est pas un fichier XML' , 
  unable:'Impossible de lire %' , 
  inconsistentlists:'Liste photos incohérentes:' , 
  untitledroute:'Sans titre' , 
  controlmenu:'Menu principal' , 
  routeprops:'Attributs de l’itinéraire' , 
  segmentprops:'Attributs du segment' , 
  waypointprops:'Attributs du point de passage' , 
  atwaypoint:'au point actuel' , 
  nosplitsegment:'[désactivé au premier point d’un segment]' , 
  labelwaypoint:'Étiqueter le point sélectionné' ,
  addaphoto:'Ajouter une photo' , 
  noaddaphoto:'[désactivé en attendant la liste photos]' , 
  undolatest:'Annuler la dernière modification' , 
  noundolatest:'Annuler [aucune modification effectuée]' , 
  redolatest:'Refaire la dernière modification annulée' , 
  noredolatest:'Refaire [aucune modification annulée]' , 
  saveroute:'Enregistrer l’itinéraire' , 
  nosaveroute:'Enregistrer [désactivé jusqu’à ce que les segments soient combinés]' , 
  account:'Compte' , 
  register:['Créer un compte','Connexion',
            'Mot de passe oublié?','Changer le mot de passe'] ,
  otkey:'Votre clé de confirmation vous a été envoyée par e-mail (vérifiez '+
        'votre dossier spam).' ,
  illegalopt:'Option illégale %0 pour %1' ,
  waitingfor:'En attendant %' , 
  aphotolist:'une liste photos... réessayez plus tard' ,
  youneedtocombine:'Il faut combiner des segments avant d’enregistrer.' , 
  untitled:'Sans titre' , 
  saveindexas:'Enregistrer %index comme' , 
  saveXas:'Enregistrer % comme' , 
  saveas:'Enregistrer comme' , 
  interpextra:'interpoler les points de passage supplémentaires' , 
  yourtimes:'Remarque : vos données de temps sont hors séquence\net ne seront pas conservées' ,
  missingalts:'altitudes manquantes de Google Elevation Service' ,
  dontwanttowait:'Si vous ne voulez pas attendre, vous pouvez' ,
  cancel:'annuler' , 
  del:'supprimer' , 
  defaults:'Réinitialiser' ,
  useinterp:'utiliser les altitudes interpolées' ,
  savemissing:'ou enregistrer avec les altitudes manquantes' ,

  addtitle:'Ajouter un titre' ,
  adddesc:'Ajouter une description' ,
  addinfo:'Ajouter des informations' ,
  editindex:'Modifier le titre de l’index' ,
  enteroffset:'Entrez le décalage en mètres à ajouter aux altitudes:' ,
  isnan:'% n’est pas un nombre' ,
  enteralt:'Entrez l’altitude (m):' ,
  enterlabel:'Entrez l’étiquette:' ,
  modlabel:'Modifier ou supprimer l’étiquette:' ,
  enterphoto:'Entrez le nom de la photo :' ,
  newphoto:'Nom de la nouvelle photo :' ,
  exitfs:'Quitter le plein écran [touche ‘esc’]' ,
  enterfs:'Entrer en plein écran [touche ‘f’]' ,
  notes:'notes' ,
  gpstrack:'trace GPS' ,
  cantundoload:'Impossible d’annuler le chargement en attendant %' ,
  deletesegment:'supprimer le segment' ,
  deleteroute:'supprimer l’itinéraire' ,
  deleteindex:'supprimer l’index' ,
  splitsegment:'diviser le segment' ,
  xferwaypoint:'transferrer le point de passage' ,
  dellabel:'supprimer l’étiquette' ,
  labelpt:'étiqueter le point de passage' ,
  editlabel:'modifier le point de passage' ,
  removelabels:'supprimer les étiquettes' ,
  edittitle:'modifier le titre' ,
  editdesc:'modifier la description' ,
  editxdesc:'modifier la description de ‘%’' ,
  editinfo:'modifier l’url de la page info' ,
  wpdel:'supprimer le point de passage' ,
  wpins:'insérer un point de passage' ,
  wpdrag:'faire glisser le point de passage' ,
  recalalts:'recalibration manuelle des altitudes' ,
  googlelats:'remplacer les altitudes par les estimations de Google' ,
  regressalts:'régression Google des altitudes' ,
  calibalts:'étalonnage additif Google des altitudes' ,
  wpalt:'définir l’altitude' ,
  wpicon:'changer l’icône' ,
  combinesegments:'combiner % segments' ,
  revsegment:'inverser le segment' ,
  dupsegment:'dupliquer le segment' ,
  optimsth:'optimiser %' ,
  optim:'optimiser' ,
  refresh:'remettre à neuf' ,
  deltimes:'supprimer les données de temps' ,
  delalts:'supprimer les altitudes des segments' ,
  delphoto:'supprimer la photo' ,
  addphoto:'ajouter une photo' ,
  modphoto:'changer de photo' ,
  clear:'effacer %' ,
  set:'définir %' ,
  swapseg:'échanger des segments' ,
  loadsth:'importer %' ,
  load:'importer' ,
  addsth:'ajouter %' ,
  unrecogaction:'Action non reconnue: %' ,
  undo:'Annuler %' ,
  redo:'Rétablir %' ,

  slightl:'léger G' , 
  sharpl:'serré G' , 
  uturnl:'U à G' , 
  turnl:'virage G' , 
  rampl:'rampe à G' , 
  forkl:'embr à G' ,
  raboutl:'rond-pt G' , 
  slightr:'léger D' , 
  sharpr:'serré D' , 
  uturnr:'U à D' , 
  turnr:'virage D' , 
  rampr:'rampe à D' , 
  forkr:'embr à D' ,
  raboutr:'rond-pt D' , 
  straight:'tout droit' , 
  merge:'fusionner' ,
  notracks:'Pas de traces' ,
  googledirs:'Google directions' ,
  shortfit:'fichier FIT prétendu est trop court' ,
  unfitfit:'fichier FIT prétendu a ‘%’ pour indicateur de format' ,
  compfit:'les fichiers FIT sont désormais traités complètement' ,
  missfit:'définition manquante dans le fichier FIT' ,
  routes:'itinéraires' ,
  numroutes: '% itinéraires' ,
  alti:'altitude' ,
  cantmeta:'Impossible d’enregistrer le’index en attendant la liste photos' ,

  emptyseg:'Segment vide' ,
  logicerr:'Erreur logique dans %' ,
  goql:'Aucune donnée d’étalonnage disponible : on a dépassé la limite Google' ,
  ginv:'Demande d’étalonnage non valide' ,
  gden:'Demande d’étalonnage refusée' ,
  gunk:'Erreur méconnu signalée pour la demande d’étalonnage : %' ,
  gcer:'Erreur d’étalonnage' ,
  gcor:'la réponse d’élévation ne correspond pas à la demande' ,
  aroute:'une itinéraire' , 
  anindex:'un index' , 
  ameta:'un métaindex' ,
  nodata:'Aucune donnée renvoyée' ,
  addto0:'D’une manière ou d’une autre, vous essayez d’ajouter une trace à un ensemble vide' ,
  addxtoy:'Vous essayez d’ajouter %0 à %1' ,
  missingurialt:'Altitudes manquantes non permises dans un import URL' , 
  missingidxalt:'Altitudes manquantes non permises pour les traces ajoutées à un index' ,
  updmulti:'Tentative de remplacer une trace à partir d’un fichier multitrace' ,
  photos:'photos' ,

  // routemasterui
  photolist:'liste photos' ,
  listnotfound:'Liste photos introuvable' ,
  title:'Titre' ,
  indextitle:'Titre d’index' ,
  desc:'Description' ,
  stats:'Chifres' ,
  date:'Date' ,
  colour:'Couleur' ,
  npoints:'%0 (%1 points de passage)' ,
  delroute:['Supprimer l’itinéraire','Supprimer les itinéraires'] , 
  updroute:['Remplacer l’itinéraire','Remplacer les itinéraires']  , 
  updnotposs:'Remplacement du fichier à partir du disque impossible' ,
  updsub:'Remplacement du sous-index non implémentée' ,
  track:['trace','traces'] ,
  emailprompt:'Entrez votre adresse e-mail :' ,
  aloneprompt:'Entrez votre mot de passe :' ,
  pwdprompt:'Entrez vos %0 et %1 :' ,
  email:'adresse e-mail' ,
  pwd:'mot de passe' ,
  confpwd:'confirmer le mot de passe' ,
  otk:'clé unique' ,
  submit:'Envoyer' ,
  rmgps:'Routemaster éditeur de traces GPS ' ,
  rmdisplays:'affiche les traces GPS (y compris les photos intégrées),' +
     ' leur permettant d’être édités et sauvegardés sur disque.' ,
  funcs:[ 'Importer les pistes gpx, tcx et fit;' ,
          'Importer les pistes définies par les pages de direction de Google Maps;' ,
          'Optimiser la trace (c’est-à-dire supprimer les points de passage superflus) ;' ,
          'Ajouter/supprimer/déplacer les points de passage ;' ,
          'Diviser les itinéraires en segments ;' ,
          'Supprimer/réorganiser/inverser les segments ;' ,
          'Fusionner les itinéraires ;' ,
          'Afficher et modifier les propriétés de l’itinéraire/segment/point ;' ,

          'Étiquetér les points de passage (par exemple, ‘Virage à G’) ;' ,
          'Ajouter des photos ;' ,
          'Ajustez les altitudes manuellement ou en utilisant le service d’altitudes Google ;' ,
          'Afficher un profil d’altitude ;' ,
          'Créer un index des itinéraires et un métaindex (index des index) ;' ,
          'Afficher l’itinéraire en plein écran ;' ,
          'Annuler et rétablir les modifications ;' ,
          'Sauvegarder en fichier tcx/gpx/fit.' ] ,
  setsave:'vue d’ensemble / enregistrer' ,
  unre:'annuler/rétablir' ,
  logindoc:['s’abonner/connexion/prefs/déconnexion : la connexion permet',
            'routemaster de mieux fonctionner mais n’est point indispensable'] ,
  propsdoc:['afficher et modifier les propriétés de l’itinéraire/segment/point',
            '(le menu d’aide est sous les propriétés de l’itinéraire)'] ,
  scisdoc:['diviser le segment au point sélectionné /',
           'étiqueter un point de passage / ajouter une photo'] ,
  save:'sauvegarder' ,
  delbksp:'[↑ suppr], [↑ retour arrière] = supprimer l’itinéraire.' ,
  wpmove:'passer au point de passage précédent/suivant;' ,
  segmove:'passer au segment précédent/suivant;' ,
  shift:'↑' ,
  centres:'centre la carte sur le point actuel;' ,
  spacedoc:'[espace] rend le point actuel glissable ou termine le glissement.' , 
  dragfor:'vers l’avant un point de passage glissable' ,
  dragback:'vers l’arrière un point de passage glissable' ,
  tabdoc:'[tab] insèrer % ;' ,
  stabdoc:'[↑ tab] insèrer % ;' ,
  deldoc:'[suppr], [retour arrière] = supprimer le point de passage ;' ,
  sdeldoc:'[↑ suppr], [↑ retour arrière] = supprimer le segment.' ,
  mouse:'Souris :' ,
  clickdoc:'[clique] sélectionne le point de passage le plus proche de la position du curseur;' ,
  sclickdoc:'[↑ clique] étend le segment actuel à la position du curseur.' ,
  extrack:'Exemple d’une trace à expérimenter' ,
  exindex:'Exemple d’un index des itinéraires' ,
  userman:'Manuel d’utilisation' ,
  rmfns:'Fonctions de routemaster:' ,
  rmtc:[ 'En utilisant Routemaster, vous acceptez d’être lié par ' ,
         'les conditions d’utilisation de Google Maps' ] ,
  hitspace:'Appuyez sur [espace] lorsque vous avez fini de faire glisser' ,
  addroute:'Ajouter un itinéraire' ,
  addindex:'Ajouter un index/un itinéraire' ,
  savemeta:'Sauvegarder l’index comme métaindex' ,
  hidear:'Masquer les chevrons' ,
  showar:'Afficher les chevrons' ,
  listroutes:'Sauvegarder une liste des itinéraires' ,
  help:'Aide' ,
  viewidx:'Afficher l’index' ,
  view:'Afficher la galerie' ,
  lastadded:'Dernière itinéraire ajoutée : ' ,
  noalts:['1 point de passage n’a pas d’altitude associée',
          '% points de passage n’ont pas d’altitudes associées'] ,
  findalts:'Déterminer les altitudes' ,
  waitalts:'En attente des résultats du service d’altitudes' ,
  notimes:'Aucune donnée de temps fourni' ,
  untimed:['1 point n’a pas de donnée de temps','% points n’ont pas de données de temps'] ,
  badtimes:'Les données de temps sont hors séquence (et seront ignorées quand vous sauvegardez le fichier)' ,
  numlabels:['1 point étiqueté','% points étiquetés'] ,
  remove:'Supprimer' ,
  numphotos:['1 photo','% photos'] ,
  numsegments:['1 segment','% segments'] ,
  combine:'Fusionner' ,
  combnote:'Notez que les segments doivent être combinés avant de sauvegarder' ,
  sepwarn:'Notez que les séparations >100m peuvent causer des problèmes sur les appareils Garmin' ,
  loadnewroute:'Importer un nouvel itinéraire' ,
  loadnewseg:'ajouter un itinéraire comme segment nouveau' ,
  saveidx:'Sauvegarder la trace comme index des itinéraires' ,
  segment:'Segment %0 de %1 (%2 points)' ,
  source:'Origine' ,
  prevopt:'Précédemment optimisé' ,
  optimised:'Optimisé' ,
  optimise:'Optimiser' ,
  optimdetail:'Optimisé (détail=%)' ,
  swapwith:'Échanger avec%0(%1)' ,
  combinewith:'Fusionner avec%0(%1)' ,
  preceding:'précédent' ,
  following:'suivant' ,
  nodeltimes:'Supprimer les données de temps (manquantes)' ,
  askgoogle:'Demander à Google de fournir les altitudes du segment' ,
  adjalts:'Ajuster les altitudes...' ,
  regress:'Corriger les altitudes du segment par régression linéaire' ,
  googleadd:'Corriger les altitudes des segments par étalonnage' ,
  vsgoogle:'La régression et l’étalonnage sont basés sur les estimations Google' ,
  repalts:'Remplacer les altitudes du segment par les estimations Google' ,
  offalts:'Ajouter un décalage manuel aux altitudes du segment' ,
  docalts:'Documentation des options d’altitude (angl.)' ,
  edit:'Modifier' ,
  photo:'Photo:' ,
  nolist:'aucune liste fournie' ,
  notpres:'manquant de %' ,
  notavail:'% n’est pas disponible' ,
  info:'Infos' ,
  enlarge:'Agrandir' ,
  nsew:'NSEO' ,
  osref:'ref grille OS' ,
  utm:'coords UTM' ,
  time:'Heure' ,
  flat:'Plat' ,
  climb:'Ascension' ,
  descend:'Descente' ,
  kmh:'km/h' ,
  segpt:'Segment %' , 
  point:'point %' ,
  mkdrag:'Faire le point glissable' ,
  dragaway:'Insérer %' ,
  xferwpt:'Transférer le point de passage vers le segment %' ,
  viewinfo:'Afficher la page infos' ,
  editurl:'Modifier URL' ,
  addurl:'Ajouter URL d’une page infos' ,
  labeldoc:'Documentation pour les étiquettes des points (angl.)' ,
  descdoc:'Documentation pour les descriptions des itinéraires (angl.)' ,
  accdoc:'Raisons pour créer un compte' ,
  tabvals:['Distance totale :','Dénivelé total :','Descente totale :',
           'Altitude maximum :','Altitude minimum :'] ,
  clearword:'Effacer' ,
  loadlist:'Importer la liste de photos depuis le Web' ,
  orweb:'ou importer depuis le web' ,
  gmaps:['Google maps','data'] ,
  photodoc:'Documentation pour l’insertion de photos (angl.)' ,
  urldoc:'L’URL peut designer un fichier TCX/GPX/KML/CSV/FIT ou '+
         'un page Google Maps donnant les directions d’un endroit à un autre.' ,

  // index.html
  nogoogle:'Impossible de charger l’API Google Maps.\n'+
    'Cela peut être dû au fait que le serveur Google ne répond pas,\n' + 
    'ou à cause d’un bug quelque part,\n' + 'ou parce que ' +
    'votre navigateur est confus\n(auquel cas le relancer pourrait l’aider).' ,
  loadrm:'Téléchargeant %...' ,
  failed:'Impossible de lire % : aucune donnée renvoyée' ,
  notadir:'% n’est pas un résultat de Google Maps' ,
  notenuff:'% a trop peu de champs pour un résultat Google' ,
  noviewbox:'** Error: % n’a pas un champs ‘viewbox’' ,
  badend:'** Erreur : % ne se termine pas par un champ de données' ,
  nodata:'** Erreur : % n’a pas de points de passage' ,
  badtrack:'URL de trace non valide (doit être .gpx, .tcx, .rte, .kml ou .fit)' ,
  badopt:'Option non reconnue: ' ,
  distance: 'Distance' ,
  altitude: 'altitude' ,

  // subsequent additions
  illegalfield:'Champ % illégal' ,
  select:'Sélectionnez un ou plusieurs fichiers TCX/GPX/KML/FIT : ' , 
  sqlerr:'Erreur SQL :' ,
  cookies:'Création d’un compte routemaster indique le consentement' +
           'à l’utilisation de cookies pour maintenir les données de session.' , 
  already:'Adresse e-mail % déjà enregistrée' ,
  formerr:'Impossible de communiquer avec le serveur : erreur=' ,
  incorrect:'Mot de passe incorrect' ,
  badotkey:'Clé unique incorrect' ,
  expired:'Clé unique expirée' ,
  baduser:'% n’est pas un compte reconnu' ,
  toosoon:'Il existe déjà une clé unique non expirée pour %' ,
  dberr:'Impossible de se connecter à la base de données : ' ,
  bademail:'Adresse e-mail illégale' , 
  logout:'Déconnexion' ,
  delact:'et clôture de compte' ,
  bugreport:'Signaler un bug/demander de l’aide' , 
  delwarn:'Fermer le compte ?\nSi vous continuez, tous vos paramètres seront perdus.' ,
  prefs:'Mettre à jour les préférences' ,
  labels: [ 'Générique' , 'Gauche serrée' , 'Gauche' , 'Gauche légère' , 
            'Tout Droit' , 'Droite légère' , 'Droite' , 'Droite serrée' , 
            'Danger' , 'Nourriture' , 'Eau' , 'Sommet' , 
            'Vallée' , 'Premiers secours' , 'Infos' , 'Obstacle' 
          ] ,
  points:'points' ,
  detail:'Détail d’optimisation' , 
  savepref:'Enregistrer comme préférence ?' , 
  language:'Langue:' ,
  maxsep:'Séparation maximale des points de passage : %m' ,
  limsep:'Limiter la séparation ?' , 
  optonload:'Optimiser au chargement ?' ,
  precision:'Précision de la trace GPS :' , 
  prob:'problème routemaster' ,
  alreadygone:'Vous êtes déjà déconnecté' ,
  devdata:'Les fichiers .fit avec les champs ‘developer data’ ne sont pas acceptés' ,
  gapsfixed:'Quelques points de passage supplémentaires ont été interpolés.' ,
  formatdoc:'Documentation pour les formats de trace (angl.)' ,
  cantplace:'Impossible de placer un point de parcours : ' ,
  untimedrecord: 'Record rencontré sans horodatage' ,
  untimedcoursept: 'Point de parcours rencontré sans horodatage' ,
  index: 'l’index' , 
  metaindex: 'le métaindex' ,
  nameforX: 'Entrez un nom pour % (sans extension)' ,
  view1: 'Afficher'
} ;

hex is a C program I wrote to walk through a FIT file, printing out the definitions, records, and coursepoints. It’s not very nicely formatted, but provides the basis of the fit-reading code I wrote for routemaster.

hex.c 
utils: memory.h 

The call is:

hex filename

and the compile line is

g++ -o hex -g -O hex.c

• checksum     • defty     • reset     • itemty     • diddy     • tetri     • main

#include "memory.h"
#include <math.h>

int checksum(unsigned char *x,int n)
{ static int crc_table[16] =
   { 0x0000, 0xCC01, 0xD801, 0x1400, 0xF001, 0x3C00, 0x2800, 0xE401,
     0xA001, 0x6C00, 0x7800, 0xB401, 0x5000, 0x9C01, 0x8801, 0x4400 } ;
  int i,crc,tmp ;

  for(crc=i=0;i<n;i++)
  { // compute checksum of lower four bits of byte
    tmp = crc_table[crc & 0xF] ;
    crc = (crc >> 4) & 0x0FFF ;
    crc = crc ^ tmp ^ crc_table[x[i] & 0xF] ;

    // now compute checksum of upper four bits of byte
    tmp = crc_table[crc & 0xF] ;
    crc = (crc >> 4) & 0x0FFF ;
    crc = crc ^ tmp ^ crc_table[(x[i] >> 4) & 0xF] ;
  }
  return crc ;
}

struct defty 
{ unsigned char size,gmsgnum,nitem,maxitem ; 
  defty() { size = nitem = maxitem = 0 ; gmsgnum = 255 ; }
  void reset(int i,int j) 
  { size = 0 ; gmsgnum = i ; maxitem = max(nitem,j) ; nitem = j ; }
} ;
struct itemty
{ unsigned char meaning,size,format,bigend ;
  itemty() { meaning = size = format = bigend = 0 ; }
  itemty(int i,int j,int k,int l) 
  { meaning = i ; size = j ; format = k ; bigend = l ; }
} ;
int diddy(unsigned char *x,unsigned char bigend) 
{ if(bigend) return (x[0]<<8) | x[1] ; else return x[0] | (x[1]<<8) ; }

int tetri(unsigned char *x,unsigned char bigend) 
{ if(bigend) return (diddy(x,1)<<16) | diddy(x+2,1) ; 
  else return diddy(x,0) | (diddy(x+2,0)<<16) ; 
}
genvector(unsigned char,ucharvector) ; 
genvector(itemty,itemtyvector) ; 

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

int main(int argc,char **argv)
{ int i,k,n,itemno,val,meaning,hdrlen,datalen ; 
  unsigned char flag,nitem,gmsgnum,lmsgnum,ilen,compass,bigend ;
  double qval ; 
  defty def[16] ; 
  itemty *item[16],it ;

  char *type, *types[] = {"Generic","Summit","Valley","Water","Food","Danger",
                        "Left","Right","Straight","First Aid",0,0,0,0,0,0,
                        "Bear left","Bear right",0,
                        "Bear left","Sharp Left","Bear right","Sharp right"} ;
  unsigned char itemlen[32] = { 1,1,1,2,2,4,4,0 , 4,8,1,2,4,0,8,8,
                                8,3,3,3,3,3,3,3 , 3,3,3,3,3,3,3,3 } ; 

  for(i=0;i<16;i++) { item[i] = 0 ; def[i] = defty() ; }

  FILE *ifl = fopenread(argv[1]) ; 
  unsigned char *buf = ucharvector(14) ;   
  n = fread(buf,1,14,ifl) ; 
  if(n<14) throw up("%s is only %d bytes long",argv[1],n) ; 
  hdrlen = buf[0] ; 
  datalen = tetri(buf+4,0) ; 
  printf("header begins:") ; 
  for(i=0;i<12;i++) printf(" %x",buf[i]) ; 
  printf("\n") ; 
  if(hdrlen==14&&(buf[12]||buf[13]))
    printf("checksum: stored=%04x, computed=%04x\n",
           buf[12]+(buf[13]<<8),checksum(buf,12)) ; 

  buf = ucharvector(buf,hdrlen+datalen) ; 
  n += fread(buf+hdrlen,1,datalen+2,ifl) ; 

  for(i=hdrlen;i<hdrlen+datalen;)
  { flag = buf[i] ; 
    lmsgnum = 15 & flag ; 
    printf("*** flag=%02x, lmsgnum=%d ***\n",flag,lmsgnum) ; 
    if(flag&0x40)
    { if(i+6>hdrlen+datalen) throw up("definition extends beyond end of file") ; 
      bigend = buf[i+2] ;
      gmsgnum = diddy(buf+i+3,bigend) ; 
      nitem = buf[i+5] ;
      printf("[byte %d]: defn: ",i) ;
      for(k=i;k<i+6;k++) printf("%02x ",buf[k]) ; 
      printf("| hdr:res:big:gma:gmb:nitem\n") ; 
      for(itemno=0;itemno<nitem;itemno++,k+=3)
        printf("   %02x %02x %02x\n",buf[k],buf[k+1],buf[k+2]) ; 
      printf("[lmsgnum=%d,gmsgnum=%d,nitem=%d]: ",lmsgnum,gmsgnum,nitem) ;
      if(i+6+3*nitem>hdrlen+datalen) 
        throw up("definition extends beyond end of file") ; 
      if(nitem>def[lmsgnum].maxitem) 
        item[lmsgnum] = itemtyvector(item[lmsgnum],nitem) ; 
      def[lmsgnum].reset(gmsgnum,nitem) ;
      for(itemno=0;itemno<nitem;itemno++)
      { k = 6 + i + 3*itemno ; 
        def[lmsgnum].size += buf[k+1] ;
        item[lmsgnum][itemno] = itemty(buf[k],buf[k+1],buf[k+2],bigend) ; 
        printf("(meaning=%d,size=%d,format=%x) ",buf[k],buf[k+1],buf[k+2]) ; 
      }
      printf(": len=%d\n",def[lmsgnum].size) ;
      i += 6 + 3*nitem ;
    }
    else if(flag&0x80) throw up("compressed timestamp") ; 
    else
    { gmsgnum = def[lmsgnum].gmsgnum ;
      if(gmsgnum==255) 
      { printf("[byte %d]: <lmsgnum=%d,len=%d, gmsgnum=?>\n",
               i,lmsgnum,def[lmsgnum].size) ; 
        throw up("no defn for lmsgnum %d",lmsgnum) ; 
      }
      printf("[byte %d]: <lmsgnum=%d,len=%d, gmsgnum=%d>\n",
             i,lmsgnum,def[lmsgnum].size,gmsgnum) ; 
      if(gmsgnum==0||gmsgnum==20||gmsgnum==21||gmsgnum==32||gmsgnum==31)
        for(k=i+1,itemno=0;itemno<def[lmsgnum].nitem;itemno++,k+=it.size)
      { it = item[lmsgnum][itemno] ;
        ilen = itemlen[it.format&31] ;

        if(gmsgnum==31) // course
        { if(it.meaning==5) 
          { printf("> coursename = \"") ; 
            for(val=0;val<it.size&&buf[k+val];val++) printf("%c",buf[k+val]) ; 
            printf("\"\n") ; 
          }
          continue ; 
        }
        meaning = -1 ; 
        if(gmsgnum==20)
        { if(it.meaning<3||it.meaning==253) meaning = it.meaning ; }
        else if(it.meaning==2||it.meaning==3) meaning = it.meaning - 2 ; 
        else if(it.meaning==1) meaning = 253 ;
        else if(it.meaning==5||it.meaning==6) meaning = it.meaning ;

        if(meaning==6) 
        { for(val=0;val<it.size&&buf[k+val];val++) printf("%c",buf[k+val]) ; 
          printf(" ") ; 
        }
        else if(ilen==1||ilen==2||ilen==4) 
        { if(ilen==1) val = buf[k] ; 
          else if(ilen==2) val = diddy(buf+k,it.bigend) ; 
          else val = tetri(buf+k,it.bigend) ; 
          if(gmsgnum==0||gmsgnum==21) 
            printf("(meaning %d,size %d,val %x) ",it.meaning,ilen,val) ; 
          else if(meaning==0||meaning==1)
          { qval = val / ((1<<30)/90.0) ;
            if(meaning==0) 
            { if(qval>=0) compass = 'n' ; else compass = 's' ; }
            else { if(qval>=0) compass = 'e' ; else compass = 'w' ; }
            printf("%.6f%c ",fabs(qval),compass) ; 
          }
          else if(meaning==2) printf("%dm ",(val/5-500)) ; 
          else if(meaning==5) 
          { if(val>=0&&val<23) type = types[val] ; 
            else if(val==46) type = "Obstacle" ; 
            else if(val==53) type = "Info" ; 
            else type = 0 ; 
            if(type==0) type = types[0] ;
            printf("*%s ",type) ; 
          }
          else if(meaning==253) printf("%ds ",val) ; 
          else printf("(%d,%d) ",it.meaning,val) ; 
        }
        else printf("[%d] ",ilen) ; 
      }
      if(gmsgnum==0||gmsgnum==20||gmsgnum==21||gmsgnum==32) printf("\n") ; 
      i += 1 + def[lmsgnum].size ;
    } 
  }
  if(n==hdrlen+datalen+2)
    printf("checksum: stored=%04x, computed=%04x\n",
           buf[hdrlen+datalen]+(buf[hdrlen+datalen+1]<<8),
           checksum(buf+hdrlen,datalen)) ; 
}