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, RTE, 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.

Geolocation of images has been added: see below.

New data types: Geographical annotations are a new point type and tours are a new route type.

New offline tool: routemasher performs some of the simple tasks also performed by routemaster. It may be downloaded onto your own desktop for offline use, reducing your dependence on internet connectivity (and on routemaster). See below for download instructions.

• 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     • adjusting altitudes     • changing the start point     • combining two tracks     • saving a track     • experimenting with routemaster     • loading from the internet     • importing Google Maps directions     • offline processing     • creating a routemaster account     • reporting bugs

Bring up routemaster, click on the Browse button, and choose a GPX, TCX, FIT, RTE 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. See below.

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.

To be specific, shift-clicking adds a new point at the end of the currently selected segment, the position of the point being the position of the click.

Bug: the Google Maps API does not return the shiftKey field of mouse clicks; therefore I have to keep track of keydowns and keyups of the shift key. This can go wrong in various ways; eg. if you hit [shift] in the process of moving to a new tab. If routemaster is treating normal clicks as shift clicks, just depress and release the shift key. Sorry.

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.

If you want to interpolate a whole detour, split the segment, add points to the end of the earlier part (as above), and recombine.

A waypoint becomes a waymark when you add a turn instruction or similar information. To attach a turn instruction to a point along a track, select it and hit the fountain pen button. (Note that you cannot add a turn instruction between two points along the track – interpolate first, if this is what you want.)

The information in a waymark comprises a label, drawn from a small set shown below, and a caption, which is a short explanatory text string. The labels (‘Turn left’, ‘Danger’ etc.) correspond to icons and are usually displayed graphically. The caption may be empty. The set of permitted labels differs between TCX and FIT.

routemaster offers a subset of the FIT labels, most of which belong to the TCX set. The ones 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.
First Aid
InfoEg. a sign board.
ObstacleEg. a locked gate. Normally needs a caption.

If you use a label 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.

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.

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 add 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 photo icons to view them as thumbnails; 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.

routemaster is an online tool which relies on online services provided by other parties, notably maps and altitude estimation. Sometimes you need to process a track without internet connectivity. For this purpose the routemasher tool may be used (which contains much of the same code). Of course, if you follow the link to it you get an internet version, but you are free to download it to your own computer (all the software is freely licenced); you may then invoke it from your browser by using the ‘File>Open...’ option.

To download routemasher, follow the link and invoke ‘File>Save Page as...’; you want to save as HTML only. Then go to the associated javascript library and save it in the same way. Put both masher.html and routemasterlib.js into the same directory on your desktop; navigate to masher.html using ‘File>Open...’, and put a bookmark into your toolbar for future use.

It allows you to preview GPS tracks, to reverse them, and to save them as GPX/TCX/FIT/RTE.

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     • copying and pasting     • rectifying post-prandial notches     • superimposed tracks     • editing     • timings     • route descriptions     • geolocating images     • extracting a gps track from osm (Openstreetmap)     • tracing a route from a satellite image     • creating a route from scratch     • geographical annotations     • 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.

You should use .rte for tracks accessed through routemaster. Other formats will work up to a point, but will not retain all the information the tracks contain.

For transfer to a GPS device, you should use the best format the device accepts: .fit preserves the greatest amount of information; .tcx comes next (but is probably not accepted by any device which doesn’t accept .fit); and .gpx is the worst.

The main difference is in handling of turn instructions. .gpx doesn’t provide anything satisfactory at all; .fit supports a larger (and better) set than .tcx.

.tcx and .fit use times to associate turn instructions with positions along a route. This is unsatisfactory because points may not have genuine times (eg. the route may have been plotted from a map), so the times are sometimes fictional. In consequence you don’t know whether a time in a .fit or .tcx track means anything or not.

.fit has the further fault that a point may have a turn instruction or an altitude, but not both; therefore, if you supply turn instructions, you have to supply points with no altitudes. These cause problems later along the line. Garmin could adopt the convention of doubling up: whenever there is a point with a turn instruction, there could be a coincident point with an altitude. Even Garmin Connect does not do this. The result is that importing software sometimes has to obtain the altitude from an elevation service, which is chargeable; so you have to spend money patching up defects in the format.

.tcx, .fit (and .rte) split a turn instruction into a ‘type’ (drawn from a small pre-specified set) and a caption, which is an arbitrary character string (perhaps limited in length). The smallness of the set allows software to associate an icon with each type, which is what you want. (It also allows a device to incorporate translations into different languages.) .gpx allows the type as well as the caption to be arbitrary character strings. This seems more flexible but in fact is the opposite: you can’t do any more with two arbitrary character strings than you can do with one, but you lose the ability to associate them with icons. .gpx also allows a whole panoply of ‘symbols’, colours, and heaven knows what, but since they’re just character strings with no standard interpretation (that I know of) all you can do is echo them without using them.

Both .gpx and .tcx are hopelessly 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 privileged status.

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

It has a documentation page.

A possible danger with using .rte for storage is that it is supported by just one person (your humble servant). You might therefore worry that you will be unable to retrieve information embedded in .rte tracks. For this reason, you are encouraged to download the routemasher offline tool, which you can use to convert from .rte to any other format without any external dependencies. See the associated paragraph for details.

There is a completely unintelligible terminology distinguishing ‘waypoints’, ‘coursepoints’, ‘trackpoints’ etc., perhaps according to whether or not they have turn instructions, or whether or not they lie on the sequence comprising a route. I have no idea what people are talking about half the time. Unfortunately I don’t know any clear way to say that a point has a ‘turn instruction’, given that the possible values include such things as ‘first aid’ (which are not turn instructions at all). Sometimes I talk more vaguely of a ‘label’.

For my part, I’m settling on my own terminology. A waypoint is a point along a track; a track is a complete ordered sequence of waypoints. If a track is broken into parts, these parts are called segments (which are nothing to do with Strava segments).

A point which is not part of the sequence comprising a track is called a geo point (or geographical annotation). A point which has a turn instruction (or similar) is called a waymark (without ceasing to be a waypoint or geo point). There is a distinction between deleting the turn instruction (which can be done through the ‘waymark’ menu invoked from the fountain pen button) and deleting the point (using the delete or the backspace key). Adding a photo to a waypoint or waymark doesn’t turn it into anything else.

It seems that GPX “waypoints” correspond to my waymarks, and this may confuse the reader. My waypoints are GPX “trackpoints”. Since there is no difference between a way and a track, but a big difference between a point and a mark, my usage is clearly preferable. According to wikipedia “In GPS navigation, a ‘route’ is usually defined as a series of two or more waypoints”. My waypoints appear to be FIT records, and my waymarked points to be FIT coursepoints.

My understanding is that GPX “waypoints” provide labels for positions in space rather than positions along a track; “tea shop” makes sense but “turn left” doesn’t, since its correctness depends on the route you are taking through the point. And I think that FIT coursepoints, on the contrary, are meant to be understood as relating to positions along a track, but are stored as points in space. Importing software is meant to assign them to positions along the track according to their time fields, which are therefore being used for an extraneous purpose. FIT coursepoints cannot have altitudes, which creates another difficulty which importing software has to deal with.

I cannot be sure of any of this; the documentation for GPX, in particular, is very inexplicit. But you can understand why I am not keen on either format, and have defined my own.

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 iconised image to bring up a thumbnail. The menu gives you the options to edit the photo designator or to view some basic info; click on the thumbnail to view a larger version. 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.

Sometimes you want to copy/paste from routemaster. Keyboard shortcuts probably won’t work, since routemaster intercepts them. The browser Edit>Copy and Edit>Paste functions should still be available. They’re useful if you’re editing a long description and want to keep a copy.

There’s a neat trick for finding locations in OSM or Google Maps. Click on the ‘Waypoint info’ button; the top line has lat and long. Select the entire string and do Edit>Copy; then paste into the search box of your online map.

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. (I assume it checks whether the extension belongs to the set { ‘gpx’, ‘tcx’, ‘fit’, ‘rte’ }.)

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.

Our usual practice is to place images on a map by collating their times with those of a GPS track. We have to do this because our cameras don’t record GPS coordinates. It’s a rather clumsy process, depending on time zones and the setting of camera clocks.

If your camera records location (as all mobile phones do), then a better method is available. Load your GPS track, click on the cogwheel, and select “Position geotagged image”. You’re given a file dialogue, in which you select one (or possibly more) .jpg files. A geo point is created at the location of the geotag, generically waymarked with the image name.

What I expect you to do next is to insert a photo at the appropriate place along the GPS track, and then delete the geo point (using the del/backspace key); but if you prefer, you can retain the geo point, associating an image with it (and presumably deleting the waymark info).

This is nothing to do with routemaster, but it’s worth mentioning here. (However you might prefer to use a mapping tool such as plotaroute.) I learnt the trick from an osm forum.

Suppose I want to convert the OSM representation of the Bwlch Sirwydd footpath into a GPS track.

Suppose you have a route, and want to modify it to incorporate a path you can see in the satellite image. Snip the route where you want to add the new points; select the segment preceding the new points; and [shift] click repeatedly where you want to add a point from the satellite image. You will probably want to delete some points from one of the old segments, and when you have done so you will combine the segments.

The feature just described is helpful if you want to create a route from scratch. Unfortunately you cannot start from a blank canvas; you have to import a route, add the extra segments, and then delete the original points.

When I do this, I often stitch together fragments of tracks I’ve downloaded from the internet, creating extra fragments from the satellite view, and sometimes adding others exported from mapping tools such as plotaroute.

When I get round to it I’d like to add a genuine create-from-scratch option. It would be implemented as a third load option besides file browser and URL load. You would type in the track’s location, and routemaster would look it up in a geographical database (presumably Google’s) and then create a route comprising a single draggable point at the specified location. The only difficulty lies in the Google interface.

GPS formats allow you to associate information with a point along a track: altitude, turn instructions, photos, etc. Sometimes you want to record this information as belonging to a point close to a track but not actually on it; and you don’t want to impose an artificial dog-leg to do so. A point not belonging to a track which serves this purpose is called a “geographical annotation” or “geo point”.

To create a geo point, shift-click to extend your current track; go into “waypoint info” (the third button along the bottom) and select “detach point”. You can now edit the point in the same way as you edit a track point. If you shift-click again, then since your current selection is a geo point, the newly created point will also be one.

You have to save the track in .rte format to preserve the geographical annotations.

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.

You are strongly urged to use RTE format for tracks you put on the web. It is more compact and retains more information than the alternatives. You are advised not to put unoptimised tracks on the web, since viewers may not all see them the same way,

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

Incidentally I provided the “z” option in the hope that Google would see the route description when a track is linked, and therefore index the track accordingly; but it doesn’t, so the option doesn’t serve its intended purpose.

.tcx, .gpx, and .rte 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.

Photos to be displayed against GPS tracks need to be stored as pix.js photo galleries. Creating such galleries requires a certain amount of work (and of computer savviness) but is easier than it used to be since I wrote the pixrescale software (Aug 2024).

So the first step is to create a suitable photo gallery. An extra detail should be attended to for images to be shown against GPS tracks. They will be displayed at first as icons (if you click on the icon you see the thumbnail; if you click on the thumbnail you see a full-size image). routemaster will generate the icon from the thumbnail if no specific icon is provided, but for bandwidth efficiency it’s best to provide icons generated specially for the purpose. These should be 52×52 jpgs; their suffix (usually ‘@i’) should be supplied through an entry in the ‘sizes’ field of the photolist, with ‘icon’ as the type and ‘52’ as the scale factor. Eg.

  <size suffix="@i" scale="52" type="icon"/>

Suitable icons are produced by the pixrescale scripts.

Once you have a suitable gallery, upload it to the web and you’ll be able to admire your own handiwork by visiting the corresponding pix.html page.

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 as .rte. It will have extra tags for the photos you’ve added and also the URL of the photo list you specified. (If you loaded the photo list from your own disc, you will need to hand-edit the URL in the GPS track to point to the web version.)

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.

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. If you load the new track it will view as an index.

When you load the index and click on a route, you will have an option to view the corresponding track. This relies on the URLs of the constituent tracks being stored in the index. If, when creating the index, you loaded them from your own disc, then routemaster will not have known what URLs to store, and will have supplied a ‘$FILE$’ placeholder. You should use a global replace within a text editor to correct them. The correct format is something like:

    <tracklink href="https://www.masterlyinactivity.com/routemaster/routes/cotswolds/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="https://www.masterlyinactivity.com/routemaster/routes/cotswolds/Upcote.gpx" mode="p"/>

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

Alternatively you may replace the GPS track in the index by a web page you want the user to go to when he or she clicks on ‘View’. You give a ‘type’ of ‘html’ to the track link to indicate that you want it to be used in this fashion, eg.

    <tracklink type="html" href="https://www.masterlyinactivity.com/cotswolds/roughstuff.html#upcote"/>

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 lines

  <index>
    https://www.masterlyinactivity.com/routemaster/routes/WesternUK.rte
  </index>

to a track stored in .rte format.

You may attach a title to the index, eg.

  <index title="Western Britain">
    https://www.masterlyinactivity.com/routemaster/routes/WesternUK.rte
  </index>

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

Routes within a tour are displayed much like routes within an index; the difference is that routes within a tour have an intrinsic order.

To create a tour, load the first route and save it as an index. Then, using a text editor, change the line near the top from

<route type="index">

to

<route type="tour">

Then load the modified file into routemaster and add the remaining routes.

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.

The final option is to save a route as segments. This is only possible if the route comprises more than one segment. It allows you to save an intermediate file when editing without having to combine the segments.

• data acquisition     • data transfer     • optimisation     • altitude smoothing     • actions list     • TCX and GPX     • Courses and tracks     • mapping     • photos     • altitudes     • downloading     • colouring tracks     • listing hues     • assigning hues     • 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 ‘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 routemaster.app (though tracks loaded from the web pass through routemaster.app). There are no cookies unless you’re logged in 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.

There are two aspects to this: firstly generating a well-separated set of hues, then assigning hues to tracks. In some cases (such as displaying a metaindex) there is a further issue of generating a set of shades of each hue. I am not satisfied with my algorithm for shades at present: I often get too pale a shade of pink. But I can give the matter attention at a future date.

A recurring difficulty is that I can never understand my own code, which is largely because source-code comments are useless unless accompanied by a diagram; so, for my own benefit, here is a graphical description of my algorithm.

I take hues from a hexagon, which is a view of the colour cube from infinity along the line (1,1,1). (Prior to 2026 I used the triangle whose vertices were R, G and B; this excluded some attractive secondary colours such as orange and purple.) The vertices of the hexagon are labelled by their indices in the array cycle and their 3-dimensional coordinates on the colour cube.

I can impose a grid of lines parallel to the sides of the hexagon; the points of intersection are the colours I shall choose. But since 3 lines meet at each point, and these overdetermine its location, I construct a non-orthogonal coordinate system using lines parallel to just two of the sides. So the various bisections determine a position (ξ,η) specified by distances from the edge connecting vertices 0 and 1 and the edge connecting vertices 4 and 5. These are easily converted into Cartesian coordinates (x,y).

Points are produced in a series of generations. The first generation is made up from lines through the vertices; the second adds lines through the midpoints of the peripheral edges; and successive generations are produced by repeated bisection. Each generation includes all the points in earlier generations, but since I want early points to be chosen before later ones, when I generate points for any generation I skip over those which have already been listed. (This is done by ensuring that the indices of the axes are not both even.)

In the diagram, the intersections of thick lines are the first generation points, and the intersections of lines which are not both thick are those added in the second generation.

In order to bound colours away from yellow and green, I discard the part of the hexagon shaded grey; legal colours therefore come from the unshaded hexagon (including its perimeter) in the second diagram. Nearly half the colour space is lost in consequence.

The colour associated with a point is found from its (x,y) coordinates. These determine the sector of the hexagon the point lies in, where sectors are triangles formed by the two ends of an edge and the centre of the hexagon. The colour is then found by interpolation over the triangle. (There are two slightly different ways in which I could interpolate parallel to an edge: either linearly along the edge or proportionally to the angle; I do the second.)

If we don’t use all the points produced in a given generation, I want to avoid clustering them at one end of the hexagon, so I extract them in grey-code order. A bit of manual reordering ensures that a natural set of colours is chosen for any size up to 8; beyond that, you have to take what the algorithm gives.

A point we have chosen may (in spite of the avoidance of yellows and greens) turn out to be too pale, so I compute its brightness using an exact formula, and if it’s greater than 0·65, I scale it crudely to bring it below that limit.

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 an iconised image 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 attempted a fix of the shift-key problem.

I added the bulk undo/redo, and fixed a couple of bugs it brought to light. I’ll reduce the amount of clicking through another time.

I removed the option to save as segments, making the normal save button available for multi-segment tracks. I ceased disallowing adding routes to metaindexes (which seemed to work – the disallowal seemed capricious) and made a fix for bounding boxes spanning 180°.

I improved the presentation of altitude profiles for flat tracks and removed a bug in the “delete’ option of the waypoint info button.

I gave routemasher the ability to edit a title. This required a small internal change to routemaster. I implemented a better method of choosing hues in routemaster and fixed a small bug in retitling.

Separated out the eye button from the cogwheel button for indexes. (The benefit is that I can add new features to the cogwheel button without confusing the user who wants to see the table.) Fix a bug in reporting unsaved changes for indexes. Default detail is now 75.

Add image geolocation. Add report of cumulative distance and ascent to waypoint info.

Cut out a prompt from the download sequence.

Add geo points and tours; perhaps a couple of bug fixes along the way. More natural default filename (can remove the dialogue box next time).

Several minor fixes to allow better display of singleton segments. Code reorganisation splitting prefsprompt out from textprompt. Correct the examples to experiment with.

Several bugfixes/tweaks. [return] wan’t returning from images correctly (in fact this was a Google Maps bug). I added a telltale. Prompts to fill in missing altitudes were being given when they weren’t needed. Addition of turn instructions wasn’t always undone correctly. I’d ill-advisedly removed the option to delete all turn instructions. I’d lost the ability to undo an optimisation.

Added the ability to read multi-layer kml, and to get the titles and descriptions from kml files.

A sizeable update. I restructured the code so that routemasterlib.js is shared by routemaster and routemasher. I replaced the bus png by parametric svg. I added the ability to save and load files as segments (so as to reinstate the ability to read the remote tcx file correctly in the test procedure). I grey out the download button when it is not activable (sligthtly dangerous if I lose track). I moved to advanced markers with icons for photos – turnpoints should be easy.

Later in the month I encountered a bug: I’d removed the code which enables routemaster to use coursepoints in FIT files which have no lat/long. I wrote some new code for the purpose. Meanwhile, since I produced coursepoints with no positions, and they pose an extra problem for importing software, I stopped producing them.

I added a login button to the entry page and wrote routemasher, plugging it from routemaster and from this documentation.

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 dozens 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   

multi-layer kml   

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

My practice has fluctuated, occasionally making large parts of routemaster open source and then withdrawing them. It’s best for the parts which define interfaces to be shared, so now that I’ve split them off from the internals (as routemasterlib.js) they’ve gone back to being open source. (One of the drawbacks of issuing code as open source is that you place yourself under some onus to retain compatibility; also, releasing the whole of routemaster would provide people with a means of circumventing online mapping suppliers’ licence policies.) pixlib.js is likewise open source for similar reasons.

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

• infocloser     • function     • resizer     • dotpath     • linepath     • arfunc     • greyout     • blackout     • bulkout     • redrawbtn     • enterFullscreen     • exitFullscreen     • findimg     • unsavedmsg     • selpoint     • distrec     • segdist     • highlight     • unhighlight     • eventually     • getbtnpos     • walkto     • keystroke     • pointstep     • shiftkey     • undraw     • redraw     • drawprofile     • predrawpoint     • drawpoint     • draw     • drawfull     • geodraw     • disconnect     • connect     • redrawconnect     • lconnect     • ldisconnect     • drawsel     • checklostedits     • genpage     • getlist     • render     • genbutton     • acfactory     • addload     • refresh     • renfactory     • wpinfo     • rellist     • dl     • cofactory1     • cofactory     • canceldl     • indexdl     • confirmeddl     • semiconfirmeddl     • reconfirmeddl     • optimaccept     • optimparms     • pen2detail     • optimmerge     • optimwork     • dgen     • posit     • vender     • unshadow     • accept     • redelta     • retitle     • respond     • restars     • routeinfo     • eyedisplay     • calwork     • manualcal     • googlecalwork     • googlecal     • googlereg     • googleadd     • help     • sidestep     • wpdelwork     • wpdel     • revsegwork     • revseg     • dupsegwork     • dupseg     • insert     • inswp     • insgeo     • draggit     • undraggit     • seginfo     • altinfo     • deltimes     • interpol     • extrapts     • combine1     • combinework     • combiner     • combine     • combinef     • combineb     • uncombine     • setalt     • delalts     • labelprompt     • editlabel     • unlabel     • photoprompt     • photomerge     • photoedit     • phadvance     • phretreat     • next     • prev     • backtogps     • display     • dodisplay     • imgwalk     • simulate     • phinfo     • snipwork     • snip     • binwork     • discard     • undiscard     • swapsegwork     • swapseg     • actiontype     • done     • donesomething     • optimswap     • refreshswap     • photoswap     • undo     • undofactory     • bulkundo     • confirmedundo     • setptpos     • redo     • redofactory     • bulkredo     • confirmedredo     • actionname     • distptseg     • distsegseg     • d3     • dsseg     • segprox     • assigncolours     • cdif     • greycode     • darken     • hexify     • hexdigit     • listhues     • extendhues     • genshades     • snipcolour     • shadeofhue     • segbounds     • getprox     • ascify     • getalts     • doelevations     • getbounds     • promoteprops     • loadtrack     • callindexify     • genarrows     • parsedesc     • parsehtml     • loaderfactory     • trackref     • pluralise     • caps     • genmarker     • imgfactory     • sqr     • phototitle     • interp     • getllpt     • readgoogle     • extendphoto     • err     • gatherpix     • thumbpix     • writeindex     • indexify     • genpixpage     • absuri     • textdiv     • highdiv     • delfactory     • updfactory     • genpicimg     • serve     • scrolltype     • scroller     • btnicon     • newcanvas     • buttons     • cloneCanvas     • buttoncell     • textcell     • appendrow     • genlink     • genindexlink     • updateacbtns     • logout     • acmenu     • logoutfactory     • doprefs     • bugreport     • acfollowon     • greyit     • emailvalidate     • pwdvalidate     • otkvalidate     • loggedin     • phpresponse     • formresponse     • blurbdiv     • rmhelpdiv     • addbull     • genpixlink     • cogwheelmenu     • addloadfactory     • dlfactory     • arfactory     • doalts     • deltimesfactory     • swapsegfactory     • seginfodiv     • optimfactory     • altinfodiv     • doclink     • walktodiv     • displayfactory     • pheditfactory     • phinfofactory     • eyemenu     • highfactory     • addcell     • listroutes     • wpinfodiv     • altfactory     • wpfactory     • kmspan     • titlediv     • titlefactory     • textpromptcleanup     • speaklang     • promptbtn     • textprompt     • submitfactory     • cancelfactory     • prefsprompt     • flagcanvas     • cclick     • pclickfactory     • defaultfactory     • labelmenu     • drawicon     • drawlabel     • genrect     • iconsel     • iconfactory     • submitfn     • genldiv     • tdivadd     • intise     • starsline     • createfunc     • starfactory     • drawxcur     • toggleprofile     • point2LatLng     • telltale     • wipetale     • zonktale

var segments=[],selected=null,actions=[],nactions=0,dragging=0 ; 
var selmarker=null,shifted=null,unsavedchanges=[] ; 
var mapdiv,routeprops,map=null,imgdiv=null,imginfo,showarrows=1 ;
var scissorsbtn,undobtn,redobtn,penbtn,setbtn=null,dlbtn,segbtn,wpbtn ; 
var acbtn=null,photobtn=null,eyebtn=null ; 
var promotable = { title:1 , desc:2 , origin:3 , index:4 , srcid:5 , 
                   stars:6 , info:7 , gallery:8 , stats:9 , tlink:10 ,
                   filename:11 , tmode:12 , ttype:13 ,
                   photo:20 , smallphoto:21
                 } ; 

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 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.segno,selected.ptno,selected.type) ; 
  else if(otype=='getlist'&&opt==0&&imginfo.carriedover) // exists but may be
  { imginfo.carriedover = 0 ; photoprompt() ; }          // obsolete
  return otype ; 
}
var infowindow = 
{ handle: null , 
  type: null , 
  closer: null ,
  divvy: null ,
  open: function(s,pos,type)
  { this.type = type ;
    if(type=='login') 
    { s.setAttribute('style','position:absolute;top:20px;right:2px;'+
                             'background:white;padding:4px') ; 
      domadd(mapdiv,s) ; 
      divvy = s ; 
    }
    else if(map)
    { 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) ; } ) ;
    }
  } , 
  close: function() // this is invoked externally (by infowindow.close())
  { var c = this.closer ;
    if(c) { this.closer = null ; c() ; return null ; }
    else if(this.type=='login') 
    { mapdiv.removeChild(divvy) ; divvy = null ; this.type = 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 && segments[0].level==0 )
  { fse = document.fullscreenElement || document.mozFullScreenElement
       || document.webkitFullscreenElement || document.msFullscreenElement  ;
    if(fse&&fse!=body)
    { mapparent = fse ; 
      if(drawpro.pro)
      { body.removeChild(drawpro.pro.prodiv) ; 
        fse.appendChild(drawpro.pro.prodiv) ; 
        body.removeChild(drawpro.pro.curdiv) ; 
        fse.appendChild(drawpro.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(tracks,opt)
{ infowindow.close() ; 
  var i ; 
  for(i=0;i<tracks.length;i++) 
    tracks[i].line.setOptions({icons:opt?tracks[i].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.ptno&&!selected.type)) 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,j,r,scale,flag=0,pos,mindist,pts ;
  var s0 = selected.segno , s1 = selected.ptno , s2 = selected.type ; 

  // this is a bit of a nightmare: if I’m displaying an image, the document 
  // onkeydown event may be intercepted by google's event listener and rerouted
  // to selpoint, so I have to pass it on. it seems that the return key is
  // treated as a click by the google onclick handler. pah!
  if(dragging) return ; 
  pos = { lat:event.latLng.lat() , lng:event.latLng.lng() } ; 
  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
  { if(s2) pts = segments[s0].geo ; else pts = segments[s0].pts ; 
    s1 = pts.length ;
    if(s2) 
    { pts.push(new pttype()) ; 
      pts[s1].setpos(pos) ; 
      geodraw(pts[s1],segments[s0].colour) ; 
    }
    else 
    { insert(pts,s1,1) ;
      pts[s1].setpos(pos) ;
      redelta(segments[s0].pts,s1) ; 
      redrawconnect(s0,s1) ; 
      drawprofile() ; 
    }
    done(['move',s0,s1,s2,pos,pos,1]) ; 
    walkto(s0,s1,s2,0) ; 
    return ; 
  }

  // select the closest point
  if(segments[0].level>0) 
  { r = distrec(segments[0],pos) ; s0 = r.ind ; mindist = r.dist ; }
  else for(mindist=null,i=0;i<segments.length;i++) 
  { if(segments[i].pts.length)
    { r = segdist(segments[i].pts,pos) ; 
      if(mindist==null||r.dist<mindist) 
      { s0 = i ; s1 = r.ind ; s2 = null ; mindist = r.dist ; }
    }
    for(j=0;j<segments[i].geo.length;j++)
    { r = dist(segments[i].geo[j].pos,pos)
      if(mindist==null||r<mindist) 
      { s0 = i ; s1 = j ; s2 = 'geo' ; mindist = r ; }
    }
  }
  // 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<50*scale) walkto(s0,s1,s2,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(tracks,shifted,segno)
{ if(segno||segno==0) selected.segno = segno ; 
  var s0=selected.segno,scroll,i,n,s , norpos = { pos:null } ;
  infowindow.close() ;
  recurse(tracks[s0],'undraw') ; 
  recurse(tracks[s0],'draw4') ; 
  scroll = highdiv(s0,shifted) ; 
  highlight.scroller = scroll.scroller ;
  recurse(tracks[s0],'north',norpos) ; 
  infowindow.open(scroll.div,norpos.pos,'highlight') ; 
}
function unhighlight()
{ var s0 = unhighlight.segno = selected.segno ;
  recurse(segments[0].pts[s0],'undraw') ; 
  recurse(segments[0].pts[s0],'draw') ; 
  if(highlight.scroller) 
  { clearInterval(highlight.scroller) ; highlight.scroller = null ; }
  if(selmarker) selmarker.setMap(null) ; 
  infowindow.handle = selmarker = null ; 
  selected.segno = -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,λ,h,e ;
  if(segments[0].level==0) h = 144 ; else { h = 48 ; if(btnno>5) btnno -= 5 ; }
  sw = bounds.getSouthWest() ; 
  ne = bounds.getNorthEast() ; 
  e = ne.lng() ; 
  if(e<sw.lng()) e += 360 ;
  λ = 64.0 / window.innerHeight ; 
  lat = λ*ne.lat() + (1-λ)*sw.lat() ; 
  λ = 0.5 + (btnno*32-h)/window.innerWidth ;
  lon = λ*e + (1-λ)*sw.lng() ;
  return { lat:lat , lng:lon>180?lon-360: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,s2,flag) 
{ selected = { segno:s0 , ptno:s1 , type:s2 } ; 
  if(segments[0].level) return highlight(segments[0].pts,shifted) ; 
  var pt = s2?segments[s0].geo[s1]:segments[s0].pts[s1] , pos = pt.pos ; 
  if(!flag) flag = 0  ;
  map.panToBounds(new google.maps.LatLngBounds(pos,pos)) ; 
  drawsel() ; 
  if(flag||(!pt.label&&!pt.photo.length)) 
  { if(flag==1) wpinfo(prefs.precision) ; 
    else if(flag==2) seginfo() ; 
    else if(flag==3) highlight(segments[0].pts,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.segno,s1=selected.ptno,s2=selected.type,slast,flag,r ;

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

  if(e.keyCode==40&&segments[0].level==0) // down arrow
  { if(s2) map.panTo(segments[s0].geo[s1].pos) ; 
    else 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( flag==3 && (e.keyCode==37||e.keyCode==39)
   && !e.shiftKey && segments[0].type=='tour' )
    selected.segno = unhighlight.segno ;
  else if(segments[0].level) { if(e.keyCode==70) enterfullscreen() ; return ; }

  if(dragging) return ; 

  if(e.keyCode==8||e.keyCode==46) // delete/backspace
  { e.preventDefault() ; 
    if(!e.shiftKey) { if(segments[0].level==0) wpdel() ; }
    else if(segments[0].level==0) discard() ; 
    else if(segments.length>1) 
    { selected.segno = s0 ; discard() ; selected.segno = -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 { r = pointstep(1) ; s0 = r.segno ; s1 = r.ptno ; s2 = r.type ; }
  }
  else if(e.keyCode==37) // backwards 
  { e.preventDefault() ;  
    if(e.shiftKey) { s1 = 0 ; s0 -= 1 ; if(s0<0) s0 = segments.length-1 ; }
    else { r = pointstep(-1) ; s0 = r.segno ; s1 = r.ptno ; s2 = r.type ; }
  }
  else return ; 
  walkto(s0,s1,s2,e.shiftKey?2:flag?1:0) ;
}
/* ------------------------------ pointstep  -------------------------------- */

function pointstep(dir)
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type , i , q , r , s ; 

  if(segments[0].level)
  { if(segments[0].type=='tour')
    { s0 += dir ; 
      if(s0>=segments[0].pts.length) s0 = 0 ; 
      else if(s0<0) s0 = segments[0].pts.length-1 ;
    }
    return { segno:s0 , ptno:s1 , type:s2 } ; 
  }
  var pos = s2 ? segments[s0].geo[s1].pos : segments[s0].pts[s1].pos ;

  if(s2)
  { for(q=null,i=0;i<segments[s0].pts.length;i++)
    { r = dist(pos,segments[s0].pts[i].pos) ; 
      if(q==null||r<q) 
      { q = r ; s = { segno:s0 , ptno:i , type:null } ; }
    }
    if(q!=null) return s ; 
  }

  if(dir>0)
  { if(s2) i = 0 ; else i = s1+1 ; 
    if(i<segments[s0].pts.length) return { segno:s0 , ptno:i , type:null } ; 
    for(i=s0+1;i<segments.length;i++) if(segments[i].pts.length) 
      return { segno:i , ptno:0 , type:null } ; 
    for(i=0;i<=s0;i++) if(segments[i].pts.length) 
      return { segno:i , ptno:0 , type:null } ; 
    return { segno:s0 , ptno:s1 , type:s2 } ; 
  }

  if(s2) i = segments[s0].pts.length-1 ; else i = s1-1 ; 
  if(i>=0) return { segno:s0 , ptno:i , type:null } ; 
  for(i=s0-1;i>=0;i--) if(segments[i].pts.length) 
    return { segno:i , ptno:segments[i].pts.length-1 , type:null } ; 
  for(i=segments.length-1;i>=s0;i--) if(segments[i].pts.length) 
    return { segno:i , ptno:segments[i].pts.length-1 , type:null } ; 
  return { segno:s0 , ptno:s1 , type:s2 } ; 
}
/* -------------------------- 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) ; 
  for(var i=0;i<segment.geo.length;i++) segment.geo[i].point.map = null ; 
  if(segment.clickhandler!=null) 
  { google.maps.event.removeListener(segment.clickhandler) ;
    segment.clickhandler = null ; 
  }
}
function redraw(i) { undraw(segments[i]) ; drawfull(segments[i]) ; }
function drawprofile() 
{ var divstyle = drawpro(drawpro.pro,segments) ; 
  toggleprofile.pcopy = drawpro.pro ; 
  toggleprofile.scopy = segments ; 
  // cursor
  if(divstyle) drawpro.pro.curdiv.setAttribute('style',divstyle) ; 
  drawxcur(drawpro.pro,selected) ; 
} 
/* ----------------------------- draw a point ------------------------------- */

function predrawpoint(colour)
{ var w = 8 , r = window.devicePixelRatio ; 
  var c = domcreate('canvas',null,'style','width:'+w+'px;height:'+w+'px') ; 
  var ctx = c.getContext('2d')  ;
  c.width = w * r ; 
  c.height = w * r ; 
  ctx.scale(r,r) ;

  ctx.lineWidth = 2 ; 
  ctx.strokeStyle = colour ;
  ctx.beginPath() ; 
  ctx.arc(w/2,w/2,w/3,0,2*Math.PI) ; 
  ctx.stroke() ; 

  var d = domcreate('div',c) ; 
  d.style.transform = "translate(0px," + Math.floor(w/2) + "px)" ;
  return d ;
}
function drawpoint(pos,colour)
{ return new google.maps.marker.AdvancedMarkerElement
      ({ map , content:predrawpoint(colour) , position:pos }) ;
}
/* ----------------------------- draw segments ------------------------------ */

function draw(segment,width) // should use drawfull in place of draw
{ if(!width) width = 2 ; 
  if(segment.pts.length==1) 
    segment.line = drawpoint(segment.pts[0].pos,segment.colour) ;
  else if(segment.pts.length>1) 
  { 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) ;
}
function drawfull(segment,width) 
{ draw(segment,width) ; 
  if(segment.level==0) for(var i=0;i<segment.geo.length;i++) 
    geodraw(segment.geo[i],segment.colour) ; 
}
function geodraw(pt,col)
{ pt.point = drawpoint(pt.pos,col) ;
  pt.setmap(map,selpoint) ; 
  if(!pt.clickhandler) 
    pt.clickhandler = google.maps.event.addListener(pt.point,"click",selpoint) ;
}
/* ----------------------- connect and disconnect segments ------------------ */

function disconnect(segno) 
{ var seg = (segments[0].type=='tour'?segments[0].pts[segno]:segments[segno]) ;
  if((segments[0].level&&segments[0].type!='tour')||seg.dots==null) return ;
  seg.dots.setMap(null) ; 
  if(seg.dothandler) 
  { google.maps.event.removeListener(seg.dothandler) ; seg.dothandler = null ; }
}
function connect(i)
{ var j,seg ; 
  if(segments[0].type=='tour') seg = segments[0].pts ;
  else { seg = segments ; if(seg[0].level||seg[i].pts.length==0) return ; }
  for(j=i+1;j<seg.length&&seg[j].pts.length==0;j++) ; 
  if(i<0||j==seg.length) return ; 
  var opos = seg[i].pts[seg[i].pts.length-1].pos ; 
  var npos = seg[j].pts[0].pos ;
  seg[i].dots = new google.maps.Polyline(new dotpath(opos,npos)) ;
  seg[i].dots.setMap(map) ;
  seg[i].dothandler = 
    google.maps.event.addListener(seg[i].dots,"click",selpoint) ;
}
function redrawconnect(s0,s1) 
{ var i ; 
  if(segments[s0].pts.length) redraw(s0) ; 
  if(s1==0) { ldisconnect(s0) ; lconnect(s0) ; }
  if(s1==segments[s0].pts.length-1) { disconnect(s0) ; connect(s0) ; }
}
function lconnect(segno)
{ var i , seg = (segments[0].type=='tour'?segments[0].pts:segments) ;
  for(i=segno-1;i>=0&&seg[i].pts.length==0;i--) ; 
  if(i>=0) connect(i) ;
}
function ldisconnect(segno)
{ var i , seg = (segments[0].type=='tour'?segments[0].pts:segments) ;
  for(i=segno-1;i>=0&&seg[i].pts.length==0;i--) ; 
  if(i>=0) disconnect(i) ;
}
/* ---------------------- 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) // args supplied only for animated arrow
{ if(!pts&&selected.type&&selected.type=='geo') 
  { pos = segments[selected.segno].geo[selected.ptno].pos ; 
    if(!selmarker) selmarker = new google.maps.Marker
      ({ position:pos, map:map, cursor:'default', icon:icons.geo , zIndex:2 }) ;
    else { selmarker.setIcon(icons.geo) ; selmarker.setPosition(pos) ; }
    greyout(scissorsbtn) ;
    return ; 
  }
  if(!pts) { pts = segments[selected.segno].pts ; ptno = selected.ptno ; }
  var ind,clen=pts.length,pos=pts[ptno].pos,pos2,λ,a,b ;
  if(clen==1) 
  { if(segments.length==1) icons.arrow.rotation = 90 ; 
    else
    { a = selected.segno + 1 ; 
      if(a==segments.length) a = 0 ;  
      icons.arrow.rotation = 
        angle(pts[0].pos,segments[a].pts[0].pos)*180/Math.PI ;
    }
  }
  else
  { if(ptno==clen-1) ind = ptno-1 ; else ind = ptno ;
    icons.arrow.rotation = angle(pts[ind].pos,pts[ind+1].pos)*180/Math.PI ;
  }

  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 = { lat:a+λ*(pos2.lat-a) , lng:b+λ*(pos2.lng-b) } ;
  }

  if(selmarker==null) selmarker = new google.maps.Marker
    ({ position:pos, map:map, cursor:'default', icon:icons.arrow , zIndex:2 }) ;
  else // avoid unnecessary redraws
  { selmarker.setIcon(icons.arrow) ;
    selmarker.setPosition(pos) ; 
  }
  if(segments[0].level) return ;
  if(drawpro.pro.m.h.length) drawxcur(drawpro.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,buscolour) 
{ var div,xmldoc,i ;
  imginfo = { pending:[] , uri:null } ; 
  if(!genpage.focused) 
  { genpage.focused = 1 ;  
    window.addEventListener("focus",()=>{ shifted = null ; }) ;
  }
  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(buscolour) ;
    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) ; 
    genpage.im = rmhelpdiv.im ; 
  }
  else render(response,trackuri,'uri',mode,'load') ; 
}
/* -------------------------------- getlist --------------------------------- */

function getlist(uri,imgtype,r1,r2) 
{ if(uri.substring(uri.length-4).toLowerCase()!='.xml')
  { alert(uri+' '+L.isnotxml) ; 
    imginfo = { status:'failed' , uri:null } ; 
    return ; 
  }
  var xhttp = new XMLHttpRequest() ;
  var p = (imginfo&&imginfo.pending)?imginfo.pending:[] ; 
  imginfo = { status:'waiting' , type:imgtype , uri , pending:p } ; 

  xhttp.onreadystatechange = function() 
  { var r,i,xmldoc,i,c,d ; 
    if(xhttp.readyState==4)
    { if(photobtn) blackout(photobtn) ;
      if(xhttp.responseText.length==0)
      { filedialogue.pixerr = r = uri ; 
        i = r.lastIndexOf('/') ;
        if(i>=0) r = r.substring(i+1) ; 
        alert(inject(L.unable,r)) ;
        imginfo = { status:'failed' , uri:null } ; 
        return ; 
      }
      filedialogue.pixerr = null ; 
      if(prefs.pixhits&&0>prefs.pixhits.indexOf(uri))
        prefs.pixhits = addtolist(prefs.pixhits,uri) ; 

      // was parselist
      xmldoc = parser.parseFromString(xhttp.responseText,"application/xml") ;
      imginfo = getphotolist(xmldoc,uri) ; 
      selcat(imginfo) ; 
      imginfo.status = 'ready' ; 
      imginfo.carriedover = 0 ; 
      imginfo.type = imgtype ;
      imginfo.uri = uri ; 
      imginfo.gallery = reluri(uri,imginfo.gallery) ; 
      imginfo.title = getcatval(imginfo.title) ; 
      imginfo.title = ( imginfo.title?imginfo.title:'' ) ; 
      imginfo.pending = [] ; 
      for(i=0;i<p.length;i++) genmarker(p[i],1) ; 
      
      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]) ; 
    }
  }
  console.log('getting photolist '+uri.substring(1+uri.lastIndexOf('/'))) ; 
  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,pts,opts,segno,proactive,r,flag,ovr,disp,s,loadflags=null,pro ;
  var extn = filename.substring(filename.length-4).toLowerCase(),newseg ;
  var olen = segments.length , smt ; 
  if(overwrite) render.overwrite = overwrite ; 
  if(origin=='refresh') { ovr = 'refresh' ; origin = p4 ; }
  else { ovr = render.overwrite ; loadflags = p4 ; }

  genpage.im = null ; 
  // purge body of everything except mapdiv
  if(mapparent==null||body==null) 
    mapparent = body = document.getElementsByTagName("body")[0] ;

  infowindow.close() ;
  keyhandler = keystroke ;
  document.addEventListener('keyup',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) ;

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

  // if we're displaying a track/index, check its photolist and load it if nec
  if(segments[0].level==0&&newseg.list!=imginfo.uri) 
  { if(!imginfo.uri) getlist(newseg.list,'uri') ; 
    else if(!newseg.list) imginfo.carriedover = 1 ; 
    else abend(L.inconsistentlists+' ' + newseg.list + ' vs.' + imginfo.uri) ; 
  }
  /* ------------------------------------------------------------------------ */

  // 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) // 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
             clickableIcons: false, // GRO POIs
             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
                         ],
             mapId: 'rmmap',
           } ;

    map = new google.maps.Map(mapdiv,opts) ;
    // https://stackoverflow.com/questions/77127884/...
    //                 ...google-maps-3-54-adding-style-to-the-map
    if(disp==google.maps.MapTypeId.ROADMAP)
    { smt = new google.maps.StyledMapType(
            [ { featureType: 'water',
                elementType: 'all',
                stylers: [ {color:'#aadaff'}, {visibility:'on'} ]
              } ,
              { featureType: 'landscape',
                elementType: 'all',
                stylers: [ {lightness:50}, {visibility:'on'} ]
              } ] ) ;
      map.mapTypes.set('styled_map',smt) ;
      map.setMapTypeId('styled_map') ;
    }

    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
    if(segments[0].level) eyebtn = genbutton('eye') ;
    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') ; 
  recurse(newseg,'geodraw') ; 
  if(newseg.type=='segments') 
  { i = segments.length - newseg.pts.length ;
    lconnect(i) ; 
    for(;i<segments.length-1;i++) connect(i) ; 
  }
  else if(segments[0].type=='tour') 
  { i = segments[0].pts.length ;
    if(newseg.type=='tour') i -= newseg.pts.length ; else i -= 1 ; 
    lconnect(i) ; 
    for(;i<segments[0].pts.length-1;i++) connect(i) ; 
  }

  // add the markers
  if(newseg.type=='segments'&&segments[0].level==0) 
  { recurse(newseg,'genmarker',{map,sel:selpoint}) ; 
    recurse(newseg,'genlabel',{map,sel:selpoint}) ; 
  }

  if(segments[0].level==0) // draw cursor and altitude profile
  { if(ovr=='load') 
      selected = {segno:0 , ptno:0 , type:segments[0].pts.length?null:'geo' } ; 
    if(drawpro.pro)
    { pro = drawpro.pro ;
      if(pro.active) flag = 1 ; 
      if(pro.prodiv) pro.prodiv.parentNode.removeChild(pro.prodiv) ;
      if(pro.curdiv) pro.curdiv.parentNode.removeChild(pro.curdiv) ; 
    }
    drawpro.pro = pro = new profiletype(1) ; 
    mapparent.appendChild(pro.prodiv) ; 
    mapparent.appendChild(pro.curdiv) ; 
    pro.map = map ; 
    pro.active = flag ; 
    drawprofile() ;
    getalts(segments,1,drawprofile) ; 
    drawsel() ; 
  }
  else selected = { segno:-1 , ptno:-1 , type:null } ; 

  render.overwrite = "add" ; 
  if(loadflags&&loadflags.indexOf('z')>=0) 
  { if(segments[0].level==0) eventually(routeinfo) ; 
    else eventually(eyedisplay) ; 
  }
}
/* ------------------------------- 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'||name=='eye') 
    u.style.cursor = 'pointer' ;
  else u.style.cursor = 'default' ;
  u.style.marginBottom = '12px' ;
  u.style.textAlign = 'center' ;

  if(name=='eye') { h = eyedisplay ; u.index = -1 ; ktitle = L.eyemenu ; }
  else 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 ; }
  else // account
  { function acfactory() 
    { return function() 
      { infowindow.open(acmenu('account'),getbtnpos(9),'account') ; } ;
    }
    h = acfactory() ; 
    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'&&ovr!='geotag') 
    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() ; 
  var s0=selected.segno , s1=selected.ptno , s2=selected.type , pos ;
  if(s2) pos = segments[s0].geo[s1].pos ; else pos = segments[s0].pts[s1].pos ;
  infowindow.open(wpinfodiv(prefs.precision),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 3 => download segments as segments (never invoked in this way)
// 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 ;
  infowindow.close() ; 
  if(e) e.preventDefault() ; 

  // save as segments if appropriate
  if(!opt&&segments[0].level==0&&segments.length>1) opt = 3 ; 

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

  // filename
  if(segments[0].level) routename = abbreviate(segments[0].filename) ;
  else routename = abbreviate(routeprops.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||opt==3)
  { if( routename[0] && routename[1]!='.fit'
          && trackextns.indexOf(routename[1])>=0 ) filename = routename[0] ;
    else if(!routename&&segments[0].level>0) 
    { if(segments[0].filename) filename = abbreviate(segments[0].filename)[0] ; 
      else filename = ascify(segments[0].title) ;
    } 
    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]) ; 
  }
  if(!filename) 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=='uri') routeprops.list = imginfo.uri ; 
    else if(imginfo.type=='uriform') routeprops.list = rellist(imginfo.uri) ; 
    else routeprops.list = '$FILE$/' + imginfo.uri ; 
  }
  
  // save segments
  if(opt==3) 
  { div = domcreate('div') ; 
    legend = L.saveas + ' ' + filename + trackextns[3] ;
    function cofactory1() 
    { return function() { indexdl(filename+'.rte',name,3) ; } ; } ;
    div.appendChild(genclickfn(cofactory1(),legend,'hr')) ;
    div.appendChild(genspan(L.combinetosave)) ; 
    infowindow.open(div,getbtnpos(8),'download') ; 
    return ; 
  }
  // save an index as itself or a route as an index
  else if(opt) { indexdl(filename+'.rte',name,opt,e) ; return ; }
  else if(segments[0].level>0) 
  { routeprops.filename = filename + '.rte' ; 
    indexdl(routeprops.filename,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 if(opt==3)
  { routeprops.level = 1 ; 
    routeprops.type = 'segments' ;
    routeprops.pts = segments ; 
    str = writeindex(routeprops,0) ; 
  }
  else
  { r = new routetype(segments[0].level?'metaindex':'index') ; 
    r.title = name ; 
    r.pts = new Array(segments.length) ; 
    r.gallery = routeprops.gallery ; 
    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) ;
  unsavedchanges = [] ; 
}

function confirmeddl(filename)
{ var nnull,i,div ; 
  dl.pending = 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 = dl.pending , seg0 = segments[0] ; 
  if(filename==null) return ;
  var i,str,mode,len=filename.length ;//filename is null
  infowindow.close() ; 
  mode = filename.substring(len-4) ;
  // record optimisation 
  routeprops.pts = seg0.pts ;
  routeprops.optim = seg0.optim ; 
  routeprops.filename = filename ; 

  if((str = writegps(routeprops,seg0.pts,seg0.geo,mode,prefs.precision))) 
  { unsavedchanges = [] ; 
    if(mode==".fit") mode = "application/octet-stream" ;
    else mode = "text/plain;charset=utf-8" ;
    saveAs(new Blob([str],{type:mode}),filename) ;
  }
  dl.pending = 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') ; 
  ldisconnect(segno) ; 
  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.ptno = 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==''&&opt!='desc') return ; 
    if(oldval&&newval&&segno==null&&opt=='desc')
    { for(list=[],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 ; 
    }
    actions[nactions++] = [ 'edit'+opt , oldval , newval , segno ,list ] ; 
    if(opt=='desc') 
    { if(segno==null) routeprops.desc = newval ; 
      else segments[segno].desc = newval ; 
    }
    else if(segno==null) 
    { setdomtitle(routeprops.title=newval) ; routeprops.filename = null ; }
    else segments[segno].title = newval ; 

    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') ; 
}
function eyedisplay()
{ infowindow.close() ; infowindow.open(eyemenu(),getbtnpos(-1),'eyemenu') ; }

/* ------------------------------- calwork --------------------------------- */

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

function manualcal()
{ infowindow.close() ; 
  var x,y,s0=selected.segno ;
  x = prompt(L.enteroffset) ;
  if(x==null) return ; 
  y = parseFloat(x) ; 
  if(isNaN(y)) { alert(inject(L.isnan,x)) ; return ; }
  calwork(segments[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[s0]],1,drawprofile) ;
}
/* ------------------------------ googlecal --------------------------------- */

function googlecal()
{ infowindow.close() ; 
  var i,s0=selected.segno,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.segno ; 
  infowindow.close() ; 
  getalts([segments[s0]],-1,drawprofile,s0) ;
}
function googleadd() 
{ var s0 = selected.segno ; 
  infowindow.close() ; 
  getalts([segments[s0]],-2,drawprofile,s0) ;
}
/* --------------------------------- help ----------------------------------- */

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

function sidestep(seg,s0,s1,pos)
{ var i,mindist
  for(mindist=null,i=0;i<seg[s0].pts.length;i++)
    if(mindist==null||dist(seg[s0].pts[i].pos,pos)<mindist)
  { mindist = dist(seg[s0].pts[i].pos,pos) ; 
    selected = { segno:s0 , ptno:i , type:null } ;
  }
  for(i=0;i<seg[s0].geo.length;i++) if(i!=s1)
    if(mindist==null||dist(seg[s0].geo[i].pos,pos)<mindist)
  { mindist = dist(seg[s0].geo[i].pos,pos) ; 
    selected = { segno:s0 , ptno:i , type:'geo' } ;
  }
}
/* --------------------------------- wpdel ---------------------------------- */

function wpdelwork(seg,s0,s1,s2,opt)
{ var i,pts=s2?seg[s0].geo:seg[s0].pts,resp=pts[s1],clen=pts.length,mindist ;

  // delete the point (but not the label/photo if we're detaching/attaching)
  if(!opt) resp.setmap(null,null) ; else if(resp.point) resp.point.map = null ;  
  if(!s2&&clen==1) resp.point.map = null ; 
  for(i=s1;i<clen-1;i++) pts[i] = pts[i+1] ;
  pts.length = clen-1 ; 

  // if a deletion (rather than detachment) determine new selection point
  if(s2&&!opt) sidestep(seg,s0,s1,resp.pos) ; 
  else if(!opt)
  { if(!pts.length) selected = { segno:s0 , ptno:0 , type:'geo' } ; 
    else selected = { segno:s0 , ptno:s1==pts.length?s1-1:s1 , type:null } ; 
  }

  if(!s2) // if we’re deleting/detaching a waypoint
  { if(s1>0) redelta(pts,s1-1) ; else if(pts.length) redelta(pts,s1) ;
    if(pts.length) redrawconnect(s0,s1?s1-1:0) ; // was (s0,s1) before 10 Jun 25
    else { ldisconnect(s0) ; disconnect(s0) ; lconnect(s0) ; }
    drawprofile() ; 
  }
  return resp ;
}
function wpdel(opt) // opt means detach
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type,i,r ;
  var seg = segments[s0] ;

  if(!opt&&seg.pts.length+seg.geo.length==1) return discard() ;
  var flag = infowindow.close() ; 
  r = wpdelwork(segments,s0,s1,s2,opt) ;
  if(s2&&opt) 
  { seg.pts.push(r) ; 
    if(seg.pts.length>1) redelta(seg.pts,seg.pts.length-1) ; 
    redrawconnect(s0,seg.pts.length-1) ; 
    drawprofile() ; 
    selected = { segno:s0 , ptno:seg.pts.length-1 , type:null } ; 
  }
  if(!s2&&opt) 
  { seg.geo.push(r) ; 
    geodraw(r,seg.colour) ; 
    selected = { segno:s0 , ptno:seg.geo.length-1 , type:'geo' } ; 
  }
  if(opt==1) done(['detach',s0,s1,s2,r]) ; 
  else if(!opt) done(['wpdel',s0,s1,s2,r]) ; 
  drawsel() ; 
  if(flag=='wpinfo') wpinfo() ; 
}
/* --------------------------------- revseg --------------------------------- */

function revsegwork(seg,s0)
{ var d=seg[s0].pts,len=d.length,lim,i ;
  ldisconnect(s0) ; 
  disconnect(s0) ; 
  flipseg(d) ; 

  if(s0==selected.segno) selected.ptno = (len-1) - selected.ptno ; 
  lconnect(s0) ; 
  connect(s0) ; 
  drawsel() ; 
  drawprofile() ;
}
/* -------------------------------------------------------------------------- */

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

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

  seg[n] = seg[s0].clone() ;
  seg[n].pts = new Array(seg[s0].pts.length) ; 
  for(i=0;i<seg[s0].pts.length;i++) seg[n].pts[i] = seg[s0].pts[i].clone() ; 
  seg[n].dots = seg[n].dothandler = null ; 
  seg[n].geo = [] ; 
  seg[n].hue = seg[s0].hue ;
  seg[n].colour = newcol ;
  draw(seg[n]) ;
  lconnect(n) ; 
  if(selected.segno==s0) selected.segno = n ; 
  drawpro.pro.active = 1 ; 
  drawprofile() ; 
  drawsel() ; 
}
/* -------------------------------------------------------------------------- */

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

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

function inswp(dir)
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type ;
  var bounds,del,pos,pts=s2?segments[s0].geo:segments[s0].pts ;
  var len = pts.length ;
  if(len==1||s2) pos = pts[s1].pos ;

  if(s2) s1 = selected.ptno = pts.length ; 
  else if(dir>=0) s1 = ( selected.ptno += 1 ) ; 
  insert(pts,s1,1) ;  
  if(len==1||s2)
  { bounds = map.getBounds() ;
    del = bounds.getNorthEast().lng() - bounds.getSouthWest().lng() ; 
    pos = { lat:pos.lat , lng: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) ; 
  if(s2) geodraw(pts[s1],segments[s0].colour) ; 
  else redelta(pts,s1) ; 
  draggit(1) ; 
}
function insgeo(pos,name,opt)
{ var s0=selected.segno , pts=segments[s0].geo , len=pts.length ; 
  pts[len] = new pttype(pos) ;  
  pts[len].setlabel('Generic',name) ; 
  geodraw(pts[len],segments[s0].colour) ; 
  if(!opt)
  { map.panTo(pos) ;
    selected = { segno:selected.segno , ptno:len , type:'geo' } ;
    walkto(selected.segno,len,'geo') ; 
  }
  if(opt!=null) done(['insgeo',selected.segno,len,pos,name]) ; 
} 
/* -------------------------------- draggit --------------------------------- */

// draggit makes the current waypoint draggable

function draggit(insparm)
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type ; 
  var start,end,i,seg=segments[s0] , pts=s2?seg.geo:seg.pts ;
  var len=pts.length , startpos , colour , pt = pts[s1] ;
  draggit.startpos = startpos = pt.pos ; 
  draggit.inserted = insparm?1:0 ; 
  infowindow.close() ; 
  bulkout(0) ; 

  if(!s2) seg.line.setMap(null) ;  

  selmarker.setMap(null) ; 
  selmarker = new google.maps.Marker(
                  { position: startpos,
                    map: map,
                    cursor: 'default',
                    icon: icons.concircle ,
                    draggable: true ,
                    zIndex: 2
                  } ) ;

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

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

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

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

  draggit.l1 = google.maps.event.addListener(selmarker,'drag',function()
  { var pos = this.getPosition() ;
    pt.setpos({lat:pos.lat(),lng:pos.lng()}) ; 
    if(!s2) 
    { draggit.seg1.setMap(null) ;
      draggit.seg1 = new google.maps.Polyline(new linepath(seg,start,end,colour)) ;
      draggit.seg1.setMap(map) ;
      if(s1==0) { ldisconnect(s0) ; lconnect(s0) ; }
      if(s1==len-1&&s0<segments.length-1) { disconnect(s0) ; connect(s0) ; }
    }
  } ) ;
 
  dragging = 1 ; 
}  
/* ------------------------------- undraggit -------------------------------- */

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

function undraggit()
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type ; 
  var i,s1dash,d=s2?segments[s0].geo:segments[s0].pts ;
  var xpos,pos=d[s1].pos ; 
  google.maps.event.removeListener(draggit.l1) ;
  dragging = 0 ; 
  d[s1].h = null ;
  if(!s2)
  { if(draggit.seg0) draggit.seg0.setMap(null) ;
    draggit.seg1.setMap(null) ;
    if(draggit.seg2) draggit.seg2.setMap(null) ;
    draw(segments[s0]) ;
    getalts(segments,100,drawprofile) ; 
    redelta(d,s1) ; 
    drawprofile() ; 
  }

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

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

function altinfo()
{ var pos = segments[selected.segno].pts[selected.ptno].pos ;
  infowindow.close() ;
  infowindow.open(altinfodiv(segments,selected.segno),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,0]) ; segments[s0].pts[s1].t = null ; }
  for(s1=0;s1<segments[s0].geo.length;s1++) if(segments[s0].geo[s1].t!=null)
  { task.push([s1,segments[s0].geo[s1].t,1]) ; segments[s0].geo[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.segno , selected.ptno ] ;
  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(segments[s0].pts,s1,n) ; 
    for(i=0;i<n;i++) 
    { lambda = (i+1) / (n+1) ; 
      pts[s1+i].setpos( { lat:lambda*npos.lat+(1-lambda)*opos.lat ,
                          lng:lambda*npos.lng+(1-lambda)*opos.lng } ) ;
    }
    for(i=0;i<=n;i++) redelta(pts,s1+i) ; 
    if(selected.segno==s0&&s1<=selected.ptno) selected.ptno += 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(seg,sa,sb)
{ var n=seg[sa].pts.length,ngeo=seg[sa].geo.length ;
  undraw(seg[sb]) ; 
  if(sb>0) ldisconnect(sb) ; 
  if(selected.segno==sb) 
  { if(selected.type) 
      selected = { segno:sa , ptno:selected.ptno+ngeo , type:'geo' } ; 
    else selected = { segno:sa , ptno:selected.ptno+n , type:null } ; 
  }
  seg[sa].pts = seg[sa].pts.concat(seg[sb].pts) ; 
  seg[sa].geo = seg[sa].geo.concat(seg[sb].geo) ; 
  if(n&&seg[sa].pts.length>n) redelta(seg[sa].pts,n) ;
}
function combinework(seg,base,n)
{ var s0,i ;
  disconnect(base+n-1) ; 
  for(s0=base+1;s0<base+n;s0++) combine1(seg,base,s0) ;
  for(s0=base+n;s0<seg.length;s0++) seg[s0-(n-1)] = seg[s0] ; 
  seg.length -= n-1 ; 
  if(selected.segno>base) for(i=base;i<selected.segno;i++)
  { selected.segno -= 1 ; 
    if(selected.type) selected.ptno += seg[i].geo.length ; 
    else selected.ptno += seg[i].pts.length ; 
  }

  redraw(base) ; 
  connect(base) ; 

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

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

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

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

  seg.length += n-1 ;
  for(s0=seg.length-1;s0-(n-1)>base;s0--) seg[s0] = seg[s0-(n-1)] ; 
  disconnect(base) ; 
  for(s0=n-1,i=task.length-5;i>=5;i-=5,s0--)
  { // the waypoints
    llen = seg[base].pts.length - task[i] ;
    seg[base+s0] = new routetype() ; 
    seg[base+s0].pts = seg[base].pts.slice(llen) ;
    seg[base+s0].pts[seg[base+s0].pts.length-1].delta = null ; 
    seg[base+s0].hue = task[i+3] ;
    seg[base+s0].colour = task[i+4] ;
    draw(seg[base+s0]) ; 
    connect(base+s0) ; 
    seg[base].pts.length = llen ; 

    // the geopoints
    llen = seg[base].geo.length - task[i+1] ;
    seg[base+s0].geo = seg[base].geo.slice(llen) ;
    seg[base].geo.length = llen ; 
    for(j=0;j<seg[base+s0].geo.length;j++) 
    {  seg[base+s0].geo[j].setmap(map,selpoint) ;
       geodraw(seg[base+s0].geo[j],seg[base+s0].colour) ; 
    }
  }

  seg[base].pts.length = task[3] ; 
  seg[base].pts[task[3]-1].delta = null ; 
  connect(base) ; 
  redraw(base) ; 

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

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

function setalt(edit,precision)
{ infowindow.close() ; 
  var s0=selected.segno,s1=selected.ptno,s2=selected.type ;
  var x,y=null,oldalt=null,pt=s2?segments[s0].geo[s1]:segments[s0].pts[s1] ;
  if(edit) 
  { oldalt = pt.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,s2,pt.h,y]) ; 
  pt.h = y ; 
  if(!s2) 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 oldlabno,label='Generic',s0=selected.segno,s1=selected.ptno ;
  var s2 = selected.type , pt = s2?segments[s0].geo[s1]:segments[s0].pts[s1] ;
  var oldcaption='' , i , str , flag = (infowindow.close()=='wpinfo') ; 

  function editlabel(caption,label) 
  { if(caption==null) label = null ; else if(!caption) caption = null ; 
    if(label==null&&oldlabno>=0) 
    { if(oldlabno>=0) label = iconic.names[oldlabno] ; else label = null ; 
      done(['editlabel',s0,s1,s2,oldcaption,null,label,null]) ; 
      pt.setlabel() ;
      if(flag) wpinfo() ; else walkto(s0,s1,s2) ; 
    } 
    else if(label!=null) 
    { label = iconic.names[label] ;
      if(oldlabno>=0) oldlabno = iconic.names[oldlabno] ; else oldlabno = null ;
      if(caption!=oldcaption||label!=oldlabno) 
      { pt.setlabel(label,caption) ; 
        pt.setlabelmap(map,selpoint) ; 
        done(['editlabel',s0,s1,s2,oldcaption,caption,oldlabno,label]) ; 
      }
      if(flag) wpinfo() ; else walkto(s0,s1,s2) ; 
    }
  }
  
  oldlabno = pt.label ; 
  if(!oldlabno) oldlabno = -1 ;  
  else for(oldcaption=pt.marker.title,i=iconic.names.length-1;i>=0;i--)
         if(i==0||iconic.names[i]==oldlabno) { oldlabno = i ; break ; }
  if(oldcaption==null||oldlabno<0) oldcaption = '' ;
  if(oldlabno<0) str = L.enterlabel ; else str = L.modlabel ; 
  labelmenu(str,oldcaption,editlabel,oldlabno) ;
}
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.segno,s1=selected.ptno,s2=selected.type,pt,photo,title,k ;
  if(e) e.preventDefault() ;
  var flag = (infowindow.close()=='wpinfo') ; 

  if(imginfo.status!='ready'||imginfo.carriedover) 
  { infowindow.open(filedialogue("list"),getbtnpos(5),'getlist') ; return ; }

  pt = s2?segments[s0].geo[s1]:segments[s0].pts[s1] ;
  photo = window.prompt(L.enterphoto,'') ;

  if(photo) 
  { done(['editphoto',s0,s1,s2,pt.photo.length,null,photo,null]) ; 
    pt.photo.push(photo) ; 
    if(pt.photo.length==1) genmarker(pt,0,map,selpoint) ; 
    else pt.photomarker.title = phototitle(pt) ;
    walkto(s0,s1,s2) ;
    return ; 
  }
  if(flag==1) wpinfo() ; 
  else if(flag==2) seginfo() ; 
  else if(flag==3) highlight(segments[0].pts) ; 
  else walkto(s0,s1,s2) ; 
}
/* -------------------------------------------------------------------------- */

function photomerge(p,oldp,newp,ind) // put newp in place of oldp at ind
{ var i ;                            // all that matters about oldp is if null
  if(!oldp) for(i=p.length;i>ind;i--) p[i] = p[i-1] ; 
  if(newp) p[ind] = newp ;
  else { for(i=ind;i<p.length-1;i++) p[i] = p[i+1] ; p.length -= 1 ; }
}
function photoedit(ind)
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type,i,save ;
  var flag = (infowindow.close()=='wpinfo') ; 
  var pt = s2?segments[s0].geo[s1]:segments[s0].pts[s1] ;
  var photo = window.prompt(L.newphoto,pt.photo[ind]) ;
  if(photo==null) //cancel
  { if(flag) wpinfo() ; else walkto(s0,s1,s2) ; return ; } 

  if(ind) save = null ; else save = pt.photomarker.content ; // save old marker
  done(['editphoto',s0,s1,s2,ind,pt.photo[ind],photo,save]) ; 

  if(photo&&pt.photo[ind]==photo) 
  { if(flag) wpinfo() ; else walkto(s0,s1,s2) ; return ; }

  photomerge(pt.photo,pt.photo[ind],photo,ind) ; 

  if(pt.photo.length==0) pt.setphotomap(null,null) ; 
  else 
  { if(ind==0) { pt.setphotomap(null,null) ; genmarker(pt,0,map,selpoint) ; }
    else pt.photomarker.title = phototitle(pt) ; 
  }
  if(flag) wpinfo() ; else walkto(s0,s1,s2) ; 
}
/* ----------------------------- 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() 
{ keyhandler = 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.segno,selected.ptno,selected.type,0) ;
}
function display(ind)
{ window.addEventListener('resize',function(){genpic('resize');}) ; 
  window.addEventListener('touchstart',startswipe,false) ;        
  window.addEventListener('touchmove',midswipe,false) ;        
  window.addEventListener('touchend',endswipe,false) ;
  keyhandler = imgwalk ; 
  infowindow.close() ; 
  if(!display.messaged)
  { display.messaged = 1 ; 
    if(!window.matchMedia("(hover: none)").matches)
      telltale(L.returnkey+L.backtogps) ; 
    else telltale(L.swipedown+L.backtogps) ; 
  }
  imgdiv = document.createElement('div') ; 
  imgdiv.setAttribute('style','position:fixed;width:100%;height:100%;'+
                      'left:0;top:0;background:black') ;
  dodisplay(selected.segno,selected.ptno,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 = { segno:s0 , ptno: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.texttitle,
         lmove?prev:null,litem,backtogps,rmove?next:null,ritem,
         infowords,pixhelpdiv(),dir,0) ; 
}
/* ------------------------------ image walk -------------------------------- */

function imgwalk(e)
{ 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.segno , s1 = selected.ptno , 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),pos,'phinfo') ; 
}
/* ------------------------- snip: apply scissors  -------------------------- */

function snipwork(seg,s0,s1,s2)
{ var i,k,newcol=snipcolour(seg[s0].hue),d0,d1,pos ; 
  undraw(seg[s0]) ; 
  seg.length += 1 ; 
  for(i=seg.length-1;i>s0+1;i--) seg[i] = seg[i-1] ; 

  seg[s0+1] = seg[s0].clone() ;
  seg[s0+1].pts = seg[s0].pts.slice(s1) ;
  seg[s0+1].geo = [] ; 
  seg[s0+1].dots = seg[s0].dots ;
  seg[s0+1].dothandler = seg[s0].dothandler ;
  seg[s0].dots = seg[s0].dothandler = null ; 
  seg[s0+1].hue = seg[s0].hue ;
  seg[s0+1].colour = newcol ;
  seg[s0].pts.length = s1 ; 
  seg[s0].pts[s1-1].delta = null ; 
  draw(seg[s0]) ;
  connect(s0) ; 
  draw(seg[s0+1]) ; 
  selected = null ; 

  // partition geo pts according to proximity
  for(i=0;i<seg[s0].geo.length;i++)
  { pos = seg[s0].geo[i].pos ;
    d0 = segdist(seg[s0].pts,pos).dist ; 
    d1 = segdist(seg[s0+1].pts,pos).dist ; 
    seg[s0].geo[i].point.map = null ; 
    if(d1<d0) 
    { geodraw(seg[s0].geo[i],newcol) ; 
      if(s2&&i==s1) 
        selected = { segno:s0+1 , ptno:seg[s0+1].geo.length , type:'geo' } ; 
      seg[s0+1].geo.push(seg[s0].geo[i]) ; 
      seg[s0].geo[i] = null ; 
    }
    else geodraw(seg[s0].geo[i],seg[s0].colour) ; 
  }
  for(k=i=0;i<seg[s0].geo.length;i++) if(seg[s0].geo[i]) 
    seg[s0].geo[k++] = seg[s0].geo[i] ;
  seg[s0].geo.length = k ; 

  if(!selected) selected = { segno:s0+1 , ptno:0 , type:null } ; 
  drawprofile() ;
  drawsel() ; 
}
function snip()
{ var s0=selected.segno,s1=selected.ptno,s2=selected.type ; 
  infowindow.close() ; 
  done(['snip',s0,s1,s2]) ; 
  snipwork(segments,s0,s1,s2) ; 
}
/* ------------------------ discard: bin n segments ------------------------- */

function binwork(segno,n)
{ var i,r,seg ; 
  if(!n) n = 1 ; 
  if(segments[0].level==0||segments[0].type=='tour') 
  { ldisconnect(segno) ; for(i=segno;i<segno+n;i++) disconnect(i) ; }

  if(n>1) 
  { r = new routetype('segments') ; 
    r.pts = new Array(n) ; 
    for(i=0;i<n;i++) r.pts[i] = segments[segno+i] ; 
  }
  else if(segments[0].level==0) r = segments[segno] ;
  else r = segments[0].pts[segno] ;

  if(segments[0].level==0) seg = segments ; else seg = segments[0].pts ; 
  for(i=segno;i<segno+n;i++) recurse(seg[i],'obliterate') ;
  for(i=segno;i<seg.length-n;i++) seg[i] = seg[i+n] ; 
  seg.length -= n ; 

  if(segments[0].level==0||segments[0].type=='tour') lconnect(segno) ;
  if(segments[0].level==0)
  { selected.ptno = 0 ; 
    selected.type = null ; 
    if(selected.segno>=segments.length) 
    { selected.segno = segments.length-1 ; 
      selected.ptno = segments[selected.segno].pts.length - 1 ; 
    }
    if(!segments[selected.segno].pts.length)
    { selected.ptno = 0 ; selected.type = 'geo' ; }
    drawprofile() ; 
    drawsel() ; 
  }
  return r ; 
}
function discard()
{ var seg = segments[0].level>0?segments[0].pts:segments , name=null ; 
  if(seg.length<=1) return ;
  var segno = selected.segno ; // selected.segno is reset by infowindow.close()
  if(segno>=0) 
  { infowindow.close() ; 
    if(segments[0].level>0) name = segments[0].pts[segno].title ; 
    done(['bin',segno,binwork(segno,1),name]) ; 
  }
}
function undiscard(segno,r)
{ var i,j,seg,m,n,pts ; 
  if(segments[0].level==0) seg = segments ; else seg = segments[0].pts ; 
  if(segments[0].level==0||segments[0].type=='tour') ldisconnect(segno) ; 
  m = seg.length ; 

  if(r.type=='segments') r = r.pts ; else r = [r] ;
  n = r.length ; 

  seg.length += n ; 
  for(i=m-segno-1;i>=0;i--) seg[segno+i+n] = seg[segno+i] ;
  for(i=0;i<n;i++) 
  { seg[segno+i] = r[i] ; 
    recurse(seg[segno+i],'draw') ; 
    recurse(seg[segno+i],'geodraw') ; 
    for(pts=seg[segno+i].pts,j=0;j<pts.length;j++)
    { if(pts[j].photo.length) genmarker(pts[j],0,map,selpoint) ; 
      pts[j].setmap(map,selpoint) ; 
    }
  }

  if(segments[0].level==0||segments[0].type=='tour') 
  { lconnect(segno) ; for(i=0;i<n;i++) connect(segno+i) ; }

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

function swapsegwork(seg,s0)
{ var i , temp = seg[s0+1] ; 
  seg[s0+1] = seg[s0] ;
  seg[s0] = temp ; 
  ldisconnect(s0) ; lconnect(s0) ; 
  for(i=0;i<2;i++) { disconnect(s0+i) ; connect(s0+i) ; }
  if(segments[0].level==0) drawprofile() ;
}
function swapseg(s0)
{ var flag = infowindow.close() ; 
  var pts = segments[0].type=='tour'?segments[0].pts:segments ; 
  done(['swapseg',s0]) ; 
  swapsegwork(pts,s0) ; 
  if(segments[0].type=='tour') return ;  
  if(selected.segno==s0) selected.segno += 1 ; 
  else if(selected.segno==s0+1) selected.segno -= 1 ; 

  if(flag=='highlight') flag = 3 ; 
  else if(flag=='seginfo') flag = 2 ; 
  else if(flag=='wpinfo') flag = 1 ; 
  else flag = 0 ; 
  walkto(selected.segno,selected.ptno,selected.type,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,s2,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][3]==something[3] 
   && actions[nactions-1][7] && something[7] ) 
                                       // don't merge change with create/delete
  { actions[nactions-1][4] = something[5] ; // caption
    actions[nactions-1][6] = something[7] ; // 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.segno) { selected.ptno = 0 ; drawsel() ; }
  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') ; 
}
/* -------------------------------------------------------------------------- */

// done(['editphoto',s0,s1,s2,ind,oldphoto,newphoto,save]) ; 
function photoswap(x,opt)
{ var ind=x[4] , d=x[7] ;
  var pt = x[3]?segments[x[1]].geo[x[2]]:segments[x[1]].pts[x[2]] ;
  photomerge(pt.photo,x[6-opt],x[5+opt],ind) ; 

  if(d&&pt.photomarker) // swap one photo for another
  { x[7] = pt.photomarker.content ; 
    pt.photomarker.content = d ; 
    if(ind==0) phototitle(pt) ; 
  }
  else if(d) // replace a deleted photo
  { x[7] = null ; 
    pt.photomarker = new google.maps.marker.AdvancedMarkerElement
      ({ map , content:d , position:pt.pos , title:phototitle(pt) }) ;
    pt.setphotomap(map,selpoint) ; 
  }
  else // undo an addition
  { x[7] = pt.photomarker.content ; pt.setphotomap(null,null) ; }
}
/* --------------------------------- undo  ---------------------------------- */

function undo()
{ infowindow.close() ;  
  var opts = actionname(actions[nactions-1]) , div ;
  div = domcreate('div',genclickfn(confirmedundo,L.undo+' '+opts,'br')) ;
  if(nactions>1&&actions[nactions-2][0]!='load') 
    domadd(div,genclickfn(bulkundo,L.bulkundo)) ;
  infowindow.open(div,getbtnpos(6),'undo') ; 
}
function undofactory(lim)
{ return function() 
  { var i ; for(i=nactions-1;i>=lim;i--) confirmedundo(i>lim) ; } 
}
function bulkundo()
{ infowindow.close() ;  
  var t=domcreate('table',null,'style','font-size:90%') , tr , td , i , f , g ;
  t.setAttribute('cellpadding',0) ;
  t.setAttribute('cellspacing',0) ;
  for(i=nactions-1;i>=0&&actions[i][0]!='load';i--)
  { f = undofactory(i) ; 
    g = genclickfn(f,i==nactions-1?L.undo:'+',null,'padding-right:10px') ;
    tr = domcreate('tr',domcreate('td',g,'style','text-align:right')) ;
    g = actionname(actions[i]) ; 
    domadd(tr,domcreate('td',genclickfn(f,actionname(actions[i])))) ;
    domadd(t,tr) ;
  }
  infowindow.open(t,getbtnpos(6),'undo') ; 
}
/* -------------------------------------------------------------------------- */

function confirmedundo(flag)
{ var i,ano=nactions-1,action=actions[ano][0],s0=actions[ano][1],s1,caption ;
  var oldcaption,task,ind,d,d2,pt,s2,pts,pos ; 
  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) ; 
  else if(action=='add') // [ 'add', s0 , n , loadprops.title , null ]
  { if(segments[0].level==0) actions[ano][4] = segments[s0] ; 
    else actions[ano][4] = segments[0].pts[s0] ;
    binwork(s0,s1) ; 
  }
  else if(action=='refresh') refreshswap(actions[ano]) ; 
  else if(action=='snip') // undo snip
  { selected = { segno:s0 , ptno:segments[s0].pts.length-1 } ; 
    undraw(segments[s0]) ; 
    combine1(segments,s0,s0+1) ; 
    for(i=s0+1;i<segments.length-1;i++) segments[i] = segments[i+1] ; 
    segments.length -= 1 ; 
    drawprofile() ;
    drawfull(segments[s0]) ;
    drawsel() ; 
  } 
  else if(action=='editlabel')  // undo create/edit/delete label
  { if(s2) pt = segments[s0].geo[s1] ; else pt = segments[s0].pts[s1] ;
    pt.setlabel(actions[ano][6],actions[ano][4]) ;
    pt.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') setdomtitle(routeprops.title=s0) ;
  else if(action=='editdesc') 
  { if(s2==null) routeprops.desc = s0 ; else segments[s2].desc = s0 ; }
  else if(action=='wpdel'||action=='detach')      
  { pts = s2?segments[s0].geo:segments[s0].pts ;
    insert(pts,s1,1) ; 
    pts[s1] = actions[ano][4] ;
    pts[s1].setmap(map,selpoint) ;
    if(!s2) { redelta(pts,s1) ; redrawconnect(s0,s1) ; }
    if(action=='detach'&&!s2) 
    { segments[s0].geo.length -= 1 ; pts[s1].type = pts[s1].point.map = null ; }
    else if(action=='detach') segments[s0].pts.length -= 1 ; 
    selected = { segno:s0 , ptno:s1 , type:s2 } ;
    drawsel() ; 
  }
  else if(action=='move')
  { if(actions[ano][6]) wpdelwork(segments,s0,s1,s2,0) ; 
    else setptpos(s0,s1,s2,actions[ano][4]) ; 
    drawsel() ; 
  }
  else if(action=='insgeo') 
  { pos = segments[s0].geo[s1].pos ; 
    wpdelwork(segments,s0,s1,'geo',0) ;
    sidestep(segments,s0,-1,pos)
    drawsel() ; 
  }
  else if(action=='recal') calwork(segments[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') 
  { if(s2) segments[s0].geo[s1].h = actions[ano][4] ;
    else { segments[s0].pts[s1].h = actions[ano][4] ; drawprofile() ; }
  }
  else if(action=='combine') uncombine(segments,actions[ano]) ; 
  else if(action=='revseg') revsegwork(segments,s0) ; 
  else if(action=='dupseg') 
  { if(selected.segno==segments.length-1) selected.segno = s0 ; 
    recurse(segments[segments.length-1],'obliterate') ; 
    ldisconnect(segments.length-1) ; 
    segments.length -= 1 ; 
    drawprofile() ;
  }
  else if(action=='swapseg') 
  { if(segments[0].type=='tour') pts = segments[0].pts ; else pts = segments ; 
    swapsegwork(pts,s0) ; 
  }
  else if(action=='stars') routeprops.stars = s0 ; 
  else if(action=='deltimes') for(i=0;i<s1.length;i++) 
  { if(s1[i][2]) segments[s0].geo[s1[i][0]].t = s1[i][1] ;
            else 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') photoswap(actions[ano],0) ; 
  else if(action=='extra') 
    for(selected={segno:s0,ptno: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') greyout(undobtn) ; 
  blackout(redobtn) ; 
  if(actiontype(actions[nactions][0])!=0&&unsavedchanges.length>0)
    unsavedchanges.length -= 1 ;  ;
  if(flag) return ; 
  if(action=='dltimes'||action=='stars') routeinfo() ; 
  else if(action=='optimise') seginfo() ; 
  else if(action=='editphoto'||action=='editlabel'||action=='setalt') 
    walkto(s0,s1,s2) ;
  else if(action=='editdesc')
  { if(s2==null) routeinfo() ; else highlight(segments[0].pts,0,s2) ; }
}
/* --------------------------------- setptpos ----------------------------------- */

function setptpos(s0,s1,s2,pos)
{ var pts = s2?segments[s0].geo:segments[s0].pts ;
  pts[s1].setpos(pos) ; 
  if(!s2) { redelta(segments[s0].pts,s1) ; redrawconnect(s0,s1) ; }
  drawsel() ; 
}
/* --------------------------------- redo  ---------------------------------- */

function redo()
{ infowindow.close() ;  
  var opts = actionname(actions[nactions]) , div ;
  div = domcreate('div',genclickfn(confirmedredo,L.redo+' '+opts,'br')) ;
  if(actions.length>nactions+1) domadd(div,genclickfn(bulkredo,L.bulkredo)) ;
  infowindow.open(div,getbtnpos(7),'redo') ; 
}
function redofactory(lim)
{ return function() 
  { var i ; for(i=nactions;i<=lim;i++) confirmedredo(i<lim) ; } 
}
function bulkredo()
{ infowindow.close() ;  
  var t=domcreate('table',null,'style','font-size:90%') , tr , td , i , f , g ;
  t.setAttribute('cellpadding',0) ;
  t.setAttribute('cellspacing',0) ;
  for(i=nactions;i<actions.length;i++)
  { f = redofactory(i) ; 
    g = genclickfn(f,i==nactions?L.redo:'+',null,'padding-right:10px') ;
    tr = domcreate('tr',domcreate('td',g,'style','text-align:right')) ;
    g = actionname(actions[i]) ; 
    domadd(tr,domcreate('td',genclickfn(f,actionname(actions[i])))) ;
    domadd(t,tr) ;
  }
  infowindow.open(t,getbtnpos(7),'redo') ; 
}
/* -------------------------------------------------------------------------- */

function confirmedredo(flag)
{ var i,action=actions[nactions][0],s0=actions[nactions][1],s1,caption,a,b,c ;
  var task,ind,photo,pt,s2,pts,seg ; 
  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=='add') 
  { undiscard(s0,actions[nactions][4]) ; actions[nactions][4] = null ; }
  else if(action=='refresh') refreshswap(actions[nactions]) ; 
  else if(action=='snip') snipwork(segments,s0,s1) ; 
  else if(action=='editlabel') // redo create/edit/delete label
  { if(s2) pt = segments[s0].geo[s1] ; else pt = segments[s0].pts[s1] ;
    pt.setlabel(actions[nactions][7],actions[nactions][5]) ;
    pt.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) ; 
    else segments[s2].title = s1 ; 
  }
  else if(action=='editdesc') 
  { if(s2==null) routeprops.desc = s1 ; else segments[s2].desc = s1 ; }
  else if(action=='wpdel') { wpdelwork(segments,s0,s1,s2,0) ; drawsel() ; }
  else if(action=='detach') 
  { selected = { segno:s0 , ptno:s1 , type:s2 } ; wpdel(2) ; }
  else if(action=='move') // ['move',s0,s1,s2,oldpos,newpos,inserted]
  { seg = segments[s0] ; 
    if(s2) pts = seg.geo ; else pts = seg.pts ;
    if(actions[nactions][6]) insert(pts,s1,1) ; 
    setptpos(s0,s1,s2,actions[nactions][5]) ; 
    if(s2) geodraw(pts[s1],seg.colour) ; 
    if(actions[nactions][5]) selected = { segno:s0 , ptno:s1 , type:s2 } ; 
  }
  else if(action=='insgeo') 
  { selected = { segno:s0 , ptno:s1 , type:'geo' } ;
    insgeo(s2,actions[nactions][4],null) ; // don’t add an action
  }
  else if(action=='recal') calwork(segments[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') 
  { if(s2) segments[s0].geo[s1].h = actions[nactions][4] ;
    else segments[s0].pts[s1].h = actions[nactions][4] ;
  }
  else if(action=='combine') combinework(segments,s0,s1) ; 
  else if(action=='revseg') revsegwork(segments,s0) ; 
  else if(action=='dupseg') dupsegwork(segments,s0) ; 
  else if(action=='swapseg') 
  { if(segments[0].type=='tour') pts = segments[0].pts ; else pts = segments ; 
    swapsegwork(pts,s0) ; 
  }
  else if(action=='stars') routeprops.stars = s1 ; 
  else if(action=='deltimes') for(i=0;i<s1.length;i++) 
  { if(s1[i][2]) segments[s0].geo[s1[i][0]].t = null ;
            else 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') photoswap(actions[nactions],1) ; 
  else if(action=='extra') 
    for(selected={segno:s0,ptno: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(flag) return ; 
  if(action=='dltimes'||action=='stars') routeinfo() ; 
  else if(action=='optimise') seginfo() ; 
  else if( action=='editphoto' || action=='editlabel' || action=='setalt'
       || (action=='move'&&actions[nactions-1][6]) ) walkto(s0,s1,s2) ;
  else if(action=='editdesc')
  { if(actions[nactions-1][3]==null) routeinfo() ; 
    else highlight(segments[0].pts,0,actions[nactions-1][3]) ; 
  }
}
/* -------------------------------- actionname ------------------------------ */

function actionname(x)
{ var i,s ; 
  if(x[0]=='bin') 
  { if(x[3]) return L.deletex + ' ' + x[3] ; 
    else 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]=='editlabel') // ['editlabel',s0,s1,s2,oldcaption,caption,oldlabel,label]
  { if(!x[7]) return L.dellabel ;
    else if(!x[6]) 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') 
  { if(x[3]==undefined||x[3]==null) return L.editdesc ;
    else return inject(L.editxdesc,segments[x[3]].title) ; 
  }
  if(x[0]=='editinfo') return L.editinfo ; 
  if(x[0]=='wpdel') return L.wpdel ; 
  if(x[0]=='move') { if(x[6]) return L.wpins ; else return L.wpdrag ; }
  if(x[0]=='recal') return L.recalalts ; 
  if(x[0]=='insgeo') return L.insgeo ; 
  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]=='detach') return x[3]?L.attach:L.detach ; 
  if(x[0]=='delalts') return L.delalts ; 
  if(x[0]=='editphoto') 
  { if(!x[6]) return L.delphoto ; 
    else if(!x[5]) return L.addphoto ;
    else return L.modphoto ; 
  }
  if(x[0]=='extra') return L.interpextra ; 
  if(x[0]=='stars')
  { if(!x[2]) 
    { 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[3]) return inject(L.loadsth,x[3]) ; else return L.load ; }
  if(x[0]=='add') return inject(L.addsth,x[3]?x[3]:L.loadnewseg)
  abend(inject(L.unrecogaction,x[0])) ;
}
/* -------------------------------------------------------------------------- */

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

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 greycode(a,n)
{ var b = new Array(n) , m , j , i , wid , nb ;
  for(wid=0;(1<<wid)<n;wid++) ; // find width of bitfield needed
  for(nb=i=0;i<(1<<wid);i++) 
  { for(m=j=0;j<wid;j++) m |= ((i>>j)&1)<<(wid-1-j) ; // bit reverse
    if(m<n) b[nb++] = a[m] ;
  }
  if(nb!=n) console.log("logic error") ; 
  return b ; 
}
// Returns greyscale "brightness" (0-1) of the given 0-255 RGB values
// https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color
function darken(x)
{ var i , v , k = [0.212655,0.715158,0.072187] ; 
  for(v=i=0;i<3;i++) v += k[i]*
             (x[i] <= 0.04045 ? x[i]/12.92 : Math.pow((x[i]+0.055)/1.055,2.4)) ;
  v = v <= 0.0031308 ? v*12.92 : 1.055 * Math.pow(v,1.0/2.4) - 0.055 ;
  if(v>0.65) v = 255*0.65/v ; else v = 255 ; 
  for(i=0;i<3;i++) x[i] = Math.floor(0.5+x[i]*v) ; 
  return x ; 
}
// 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 listhues(n)
{ var i,j,k,lev,l,nl,c,nc,clen,ξ,η,jst,jend,x,y,θ,sect,hue,q ; 
  var π=Math.PI,sq=Math.sqrt(3) ; 
  var cycle = [ [1,0,0],[1,0,1] , [0,0,1],[0,1,1] , [0,1,0],[1,1,0] , [1,0,0] ];

  for(l=[],lev=1,nl=0;;lev*=2) 
  { for(clen=3*(lev+1)*(lev+1),c=new Array(clen),nc=i=0;i<=1.25*lev;i++)
    { ξ = (sq/2)*i / lev ; 
      if(i<=lev) { jst = lev-i ; jend = 2*lev ; }
      else { jst = 0 ; jend = 3*lev-i ; }

      for(j=jst;j<=jend;j++) if(lev==1||((j&1)||(i&1))) if(i<=5*lev/4&&j>=lev/2)
      { η = (sq/2)*j / lev ; 
        x = η - sq/2 ; 
        y = 1 - 2*ξ/sq - x/sq ;
        if(Math.abs(x)<0.001&&Math.abs(y)<0.001) hue = [0.5,0.5,0.5] ; 
        else
        { θ = ( 5*π/2 - Math.atan2(y,x) ) % (2*π) ;
          sect = Math.floor(θ/(π/3)) ; 
          θ = θ % (π/3) ;
          μ = Math.sqrt(x*x+y*y) / ((sq/2)/Math.sin(θ+π/3)) ;
          λ = θ / (π/3) ; 
          for(hue=[0,0,0],k=0;k<3;k++) 
          { q = (1-μ)/2 + μ*( (1-λ)*cycle[sect][k] + λ*cycle[1+sect][k] ) ;
            hue[k] = q>1?1:(q<0?0:q) ;
          }
        }
        if(nc==clen) console.log('logic error') ; 
        c[nc++] = darken(hue) ; // darken bright colours
      }
    }
    if(lev>1) 
    { l = l.concat(greycode(c,nc)) ; 
      if(lev==2) 
      { x = l[3] ; l[3] = l[7] ; l[7] = x ; // bring orange up
        l.splice(6,0,[128,128,128]) ;       // insert grey
      }
    }
    else l = [c[0],c[3],c[1],c[2]] ; // red, blue, magenta, grey
    if(l.length>=n) return l.slice(0,n) ; 
  }
}
// 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,it,pts ;
  // find maxima and minima
  for(pts=route.pts,it=0;it<2;it++,pts=route.geo) for(i=0;i<pts.length;i++) 
  { lat = pts[i].pos.lat ; 
    lon = 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 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 ;
}
/* --------------------------------- getalts -------------------------------- */

// 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(segs,thresh,action,doneaction,m,c)
{ var s0,start,end,n,pts,npts,flag,lox,loxpos,loxind,p,i,j,k,l,len,sel ; 
  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(!getalts.altthresh||thresh<getalts.altthresh) 
      getalts.altthresh = thresh ; 
    getalts.pending = 0 ; 
    return ; 
  }
  else if(thresh==null) { getalts.pending = 0 ; return ; }

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

  if(getalts.altthresh&&getalts.altthresh<thresh) thresh = getalts.altthresh ;
  p = flatten(segs,1) ; 

  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 = [] ; 
      getalts.altthresh = altcallback = null ; 
      if(getalts.action) { getalts.action() ; getalts.action = null ; }
      getalts.pending = 0 ;
      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++) 
    { sel = p[k].sel ;
      if(sel.type) pts = segs[sel.segno].geo ; else pts = segs[sel.segno].pts ;
      lox[ind++] = pts[sel.ptno] ; 
    }
    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 point lox is an array of points whose altitudes need adjusting, 
  // loxpos is an array of at most 500 positions and loxind holds the indexes
  // in lox of the entries in loxpos

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

  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,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(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 = segs[0].pts[i].h ;
        if(y!=null) segs[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) ; 
      getalts.pending = 0 ; 
      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(segs,thresh,action) ; 
  } /* end of doelevations */
  /* ------------------------------------------------------------------------ */

  if(thresh==-3) { doelevations(null,null,m,c) ; getalts.pending = 0 ; }
  else 
  { getalts.pending = 1 ; 
    getalts.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 ----------------------- */

// 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,pts ; 
  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) ;
  newseg = readgps(response,filename) ; 
  if(!newseg||!newseg.pts.length) 
  { alert(inject(L.nodata,shortname)) ; return null ; }
  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(k=0;k<2;k++)
      for(pts=k?newseg.geo:newseg.pts,i=0;i<pts.length;i++) 
        if(pts[i].h==null||pts[i].h==undefined) abend(L.missingurialt) ; 

  // check legality of load level for addition
  if(ovr=='add') 
  { if(segments[0].level==0&&newseg.level==1&&newseg.type=='segments') ; 
    else if( newseg.level>segments[0].level || newseg.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')
  { if( segments.length && segments[0].level==0
     && newseg.level==1 && newseg.type=='segments' ) ; 
    else if(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.uri) imginfo.carriedover = 1 ; 
    // 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(selmarker) selmarker.setMap(null) ; 
    selmarker = null ; 
    actions = [] ; 
    unsavedchanges = [] ; 
    nactions = dragging = 0 ; 
    routeprops = new routetype() ;
    for(i=0;i<segments.length;i++) 
    { recurse(segments[i],'obliterate') ; disconnect(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') ; 
    newseg = callindexify(newseg) ; 
    segments[0].pts[updseg] = newseg ; 
    newseg.tmode = oldroute.tmode ; 
    if(newseg.level) { newseg.hue = oldroute.hue ; genshades(newseg) ; }
    else newseg.colour = oldroute.colour ; 
    if(segments[0].level==1&&newseg.level==0) recurse(newseg,'arrows') ;
    actions[nactions++] = [ 'refresh' , updseg , oldroute ] ;
  }

  // loading an index or metaindex
  else if(newseg.level&&newseg.type!='segments'&&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||newseg.type=='segments')
        && (segments.length==0||segments[0].level==0) )
  { if(newseg.type=='segments') n = newseg.pts.length ; else n = 1 ; 
    actions[nactions++] = [ ovr , segments.length , n , newseg.title , null ] ;

    if(newseg.optim||newseg.type=='segments') dooptim = 0 ; 
    else if(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(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))) ; 
      }
    }

    if(newseg.list&&newseg.list!=imginfo.uri) getlist(newseg.list,'uri') ; 
    flag = promoteprops(newseg,routeprops) ; 
    if(flag&(1<<promotable.title)) setdomtitle(newseg.title) ; 
    else if(ovr=='load') setdomtitle(L.untitledroute) ; 

    if(newseg.type!='segments') 
    { s = clone(newseg) ; 
      s.type = 'segments' ;
      s.level = 1 ; 
      s.pts = [newseg] ;
      newseg = s;
    }
    col = extendhues(n,hues) ; 
    for(i=0;i<n;i++)
    { newseg.pts[i].hue = col[i] ; 
      q = 0.5 * ( 2 - col[i][1]/255 ) ;
      newseg.pts[i].colour = hexify([q*col[i][0],q*col[i][1],q*col[i][2]]) ; 
      segments.push(newseg.pts[i]) ; 
    }
  }

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

function genarrows(seg)
{ var sep = (segments[0].type=='tour')?20:10 ; 
  var ind = choosearrows(seg.pts,null,sep) , icon , i , n = ind.length ; 
  seg.arrows = new Array(n) ; 
  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(i=0;i<n;i++) 
    seg.arrows[i] =  { icon:icon , offset:(100*ind[i].x).toFixed(8) + '%' } ;
}
/* -------------------------------- 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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      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,
      anchor: new google.maps.Point(11,22),
      xmid:11
    } 
] ,
  // 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 
  } ,
  // icon for geo points
  geo:
  { path: "M 4.5 0  A 4.5 4.5 0 1 0 4.5 9  A 4.5 4.5 0 1 0 4.5 0 ",
    fillColor: 'black',
    fillOpacity: 0,
    strokeColor: 'black',
    strokeWeight: 1.5,
    strokeOpacity: 1,
    anchor: new google.maps.Point(4.5,4.5),
    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 ] ] ;

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

// member functions
pttype.prototype.geticon = function()
{ var ind = iconic.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&&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&&!this.photomarker) return ;
  if(this.photomarker) this.photomarker.map = m ; 
  if(this.photohandler&&!m) 
  { google.maps.event.removeListener(this.photohandler) ;
    this.photohandler = null ; 
  }
  if(m&&seller&&!this.photohandler&&this.photomarker) 
    this.photohandler = this.photomarker.addListener('click',seller) ;
} ;
pttype.prototype.setgeomap = function(m) { if(this.point) this.point.map = m ; }

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.setpos = function(p) 
{ if(p) this.pos = p ; 
  if(this.label) this.marker.setPosition(this.pos) ; 
  if(this.photo.length>0) this.photomarker.position = this.pos ; 
  if(this.point) this.point.position = p ; 
} ;
pttype.prototype.setmap = function(m,seller) 
{ this.setlabelmap(m,seller) ; 
  this.setphotomap(m,seller) ; 
  this.setgeomap(m) 
} ;
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 ; 
}
/* -------------------------------------------------------------------------- */

// img = 0: haven't got imginfo yet
// img = 1: have got imginfo but not loaded the image
// img = 2: now loaded as thumbnail, need to update the photo marker
// img = 3: now loaded as icon,       "   "     "    "    "     "

function genmarker(pt,opt,el,selpoint) 
{ var w=36,h=30,name=pt.photo[0],k=null,item,icon, r = window.devicePixelRatio ; 
  if(!opt) pt.photomarker = null ; // make sure it isn't there
  if(imginfo.status=='ready') 
  { k = findimg(name) ; if(k) item = imginfo.sect[k[0]].list[k[1]] ; }

  if(opt==1&&k)
  { function imgfactory(pt,o,img) { return function() {genmarker(pt,o,img);} }
    img = domcreate('img') ; 
    icon = iconjpg(item,r*26) ; 
    if(icon&&icon.scale>=26*Math.min(r,2)) 
    { img.onload = imgfactory(pt,3,img) ; img.src = icon.url ; }
    else { img.onload = imgfactory(pt,2,img) ; img.src = jpg(item,-1) ; }
    return ; 
  }

  var c = domcreate('canvas',null,'style','width:'+w+'px;height:'+h+'px') ; 
  var ctx = c.getContext('2d') , mind , ofsx=0 , ofsy=0 , nw , nh , d ;
  c.width = w * r ; 
  c.height = h * r ; 
  ctx.scale(r,r) ;

  if(opt==2&&k)
  { img = el ; 
    nw = img.naturalWidth?img.naturalWidth:item.thumbshape[0] ; 
    nh = img.naturalHeight?img.naturalHeight:item.thumbshape[1] ; 
    if(nw<nh) { mind = nw ; ofsy = Math.floor((nh-nw)/2) ; }
    else { mind = nh ; ofsx = Math.floor((nw-nh)/2) ; }
    ctx.drawImage(img,ofsx,ofsy,mind,mind,2,2,h-4,h-4) ; 
  }
  else if(opt==3) ctx.drawImage(el,2,2,h-4,h-4) ; 

  ctx.lineWidth = 0 ; 
  ctx.fillStyle = 'purple' ;
  ctx.moveTo(h,h/2-2) ; 
  ctx.lineTo(w,h/2) ; 
  ctx.lineTo(h,h/2+2) ; 
  ctx.fill() ; 

  ctx.lineWidth = 2 ; 
  ctx.strokeStyle = 'purple' ; 
  sqr(h-2,2,1) ; 

  if(opt==1&&!k) 
  { ctx.lineWidth = 2 ; 
    ctx.strokeStyle = 'purple' ; 
    ctx.beginPath() ; 
    ctx.moveTo(1,h-1) ; 
    ctx.lineTo(h-1,1) ; 
    ctx.stroke() ; 
  }
  else 
  { ctx.lineWidth = 1 ; 
    ctx.strokeStyle = 'white' ; 
    sqr(h-3,1.5,1.5) ; 
  }
  
  d = domcreate('div',c) ; 
  d.style.transform = "translate(-"+(w/2)+"px,"+(h/2)+"px)" ;
  if(pt.photomarker) // opt >= 1 
  { if(opt==0) console.log("photomarker with opt==0") ; // temp debugging
    pt.photomarker.content = d ; 
    if(opt>1) pt.photomarker.title = phototitle(pt) ;
  }
  else // opt = 0 
  { if(opt>0) console.log("no photomarker yet opt>0") ; // temp debugging
    pt.photomarker = new google.maps.marker.AdvancedMarkerElement
      ({ map , content:d , position:pt.pos , title:phototitle(pt) }) ;
    if(imginfo.status=='ready') genmarker(pt,1) ; 
    else imginfo.pending.push(pt) ;
    pt.setphotomap(el,selpoint) ;
  }

  function sqr(h,r,ofs)
  { ctx.beginPath() ; 
    ctx.moveTo(ofs,ofs+r) ; 
    ctx.lineTo(ofs,ofs+h-r) ; 
    ctx.arc(ofs+r,ofs+h-r,r,-Math.PI,-Math.PI*1.5,1) ; 
    ctx.lineTo(ofs+h-r,ofs+h) ; 
    ctx.arc(ofs+h-r,ofs+h-r,r,-1.5*Math.PI,-Math.PI*2,1) ; 
    ctx.lineTo(ofs+h,ofs+r) ; 
    ctx.arc(ofs+h-r,ofs+r,r,0,-Math.PI*0.5,1) ; 
    ctx.lineTo(ofs+r,ofs) ; 
    ctx.arc(ofs+r,ofs+r,r,-Math.PI*0.5,-Math.PI,1) ; 
    ctx.stroke() ;
  }
}
/* -------------------------------------------------------------------------- */

function phototitle(pt)
{ var k,title ; 
  title = pt.photo[0] ; 
  if( imginfo.status=='ready' && (k=findimg(title)) )
    title = imginfo.sect[k[0]].list[k[1]].texttitle ;
  if(pt.photo.length>1) title += ' (+' + (pt.photo.length-1) + ')' ;
  return title ;
}
/* -------------------------------------------------------------------------- */

function interp(x,y,lamda)
{ var p = google.maps.geometry.spherical.interpolate(x,y,lamda) ; 
  return { lat:p.lat() , lng:p.lng() }
}
/* -------------------------------------------------------------------------- */

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({lat:poly[k].lat(),lng:poly[k].lng()})) ;
    pts.push(end) ; 
  }

  pts = squash(pts) ; 
  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 ;
}
/* ------------------------------- gatherpix  ------------------------------- */

function extendphoto(photo,done,name)
{ var item,k,kbest,img,scale,imgscale,src,sizes ;
  function err(x) { return Math.abs(x-2.5) ; }

  var ind = findimage(imginfo.sect,name) ;
  if(!ind) return ; 

  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&&item.hithumb.length) 
  { for(imgscale=k=0;k<item.hithumb.length;k++) 
      if(!imgscale||err(item.hithumb[k].scale)<err(imgscale))
    { kbest = k ; imgscale = item.hithumb[k].scale ; }
    img.srcset = item.filename + item.hithumb[kbest].suffix + '.jpg' ;
    img.scale = imgscale ;
  }
  else 
  { sizes = item.sizes ; 
    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='',type=index.type ; 
  for(i=0;i<nesting;i++) blx += '  ' ;

  if(nesting==0)
    str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
     '<!-- https://www.routemaster.app/software/routemaster.html -->\n' ;
  str += blx + '<route type="'+type+'">\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,index.pts[i].geo,
                         type=='segments'?prefs.precision:0,nesting+1,type) ; 
  }

  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) // can be satisfied when adding to an index
        getlist(route.list,'uri',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,type:null}) 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(i?r.pts:decimate(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 genpixpage(tag,gallery)
{ return '<' + tag + ' href="' + gallery.href + '">' + gallery.title + 
         '</' + tag + '>' ;
}
/* -------------------------------------------------------------------------- */

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

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],col ; 
  var link , nfetched = 2 , sect = imginfo.sect , url = document.location.href ;
  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] ;
  i = url.indexOf('?') ;
  if(i>=0) url = url.substring(0,i) ; 

  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) div.appendChild(prettystats(L.stats,seg.stats)) ; 
  if(seg.date) div.appendChild(textdiv(L.date,seg.date)) ; 

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

  if(segments[0].type=='tour')
  { var segs = segments[0].pts ; 
    if(segno>0) div.appendChild(genclickfn(swapsegfactory(segno-1),
                  inject2(L.swapwith,segs[segno-1].colour,
                          segs[segno-1].title+', '+L.preceding),'br')) ;
    if(segno<segs.length-1) div.appendChild(genclickfn(swapsegfactory(segno),
                  inject2(L.swapwith,segs[segno+1].colour,
                          segs[segno+1].title+', '+L.following),'br')) ;
  }

  dellink = updlink = golink = col = null ; 

  // route colour
  if(url.substring(url.length-8)=='test.php') col = '[col='+seg.colour+']' ; 
  
  // delete route
  function delfactory(i) 
  { return function() { selected.segno = 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$')
  { if(seg.ttype=='html') link = seg.tlink ; 
    else link = trackref(base.origin,seg.tlink,1) ; 
    golink = genlink(link,'['+L.view1+']',1) ;
    golink.setAttribute('onclick',"infowindow.close()") ; 
    if(seg.ttype!='html') 
      updlink = genclickfn(updfactory(segno),'['+caps(L.refresh)+']') ;
  }
  
  if(dellink||golink||col) 
  { d = domcreate('div') ; 
    if(golink) { domadd(d,golink) ; k = 1 ; } else k = 0 ; 
    if(dellink) { if(k) domadd(d,' : ') ; domadd(d,dellink) ; k = 1 ; }
    if(updlink) { if(k) domadd(d,' : ') ; domadd(d,updlink) ; k = 1 ; }
    if(col) { if(k) domadd(d,' : ') ; domadd(d,col) ;  }
    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 ; 
      img = genimage(sect[ind[0]].list[ind[1]],-1,{loadfunc:loadfunc}) ;
      return doim(img).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,q=0.24,r,sign,twopi=2*Math.PI ;
  var eyeparms = // svg bezier path for an eye in a 100x100 box
    [ 50.000,78.706, 34.898,78.706, 19.759,69.703,  5.000,51.948, 
                      4.061,50.818,  4.061,49.181,  5.000,48.052, 
                     19.759,30.297, 34.898,21.295, 50.000,21.295, 
                     65.100,21.295, 80.241,30.297, 95.000,48.052, 
                     95.939,49.181, 95.939,50.818, 95.000,51.948, 
                     80.241,69.702, 65.100,78.706, 50.000,78.706 ] ; 

  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=='eye')
    { ctx.beginPath() ; 
      ctx.lineWidth = 3 ; 
      ctx.moveTo(q*eyeparms[0],q*eyeparms[1]) ;
      for(i=0;i<6;i++) 
        ctx.bezierCurveTo(q*eyeparms[2+6*i],q*eyeparms[3+6*i],
                          q*eyeparms[4+6*i],q*eyeparms[5+6*i],
                          q*eyeparms[6+6*i],q*eyeparms[7+6*i]) ; 
      ctx.stroke() ;
      ctx.beginPath() ; 
      ctx.arc(q*50,q*50,q*17,0,twopi) ;    
      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++)
  { if(gifs[i]==-1) nobr.appendChild(gifs[2]) ;
    else nobr.appendChild(cloneCanvas(btnicon(gifs[i]).black)) ;
    domadd(nobr,' ') ; 
  if(i==1&&(gifs[0]==-1||gifs[1]==-1)) break ; 
  }
  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 updateacbtns(opt)
{ var i,v,node ; 
  if(acbtn)
  { if(opt==0) v = L.register[1] + '/' + L.register[0] ; 
    else v = L.account ;
    acbtn.ui.greytitle = acbtn.blacktitle = v ; 
    v = btnicon(opt) ; 
    acbtn.blackimg = v.black ;
    acbtn.greyimg = v.grey ;
    redrawbtn(acbtn,-1) ; 
  }
  else for(node=blurbdiv,i=0;i<2;i++,node=genpage) if(node.im)
  { v = cloneCanvas(btnicon(opt).black) ; 
    node.im.parentNode.replaceChild(v,node.im) ; 
    node.im = v ; 
  }
}
/* -------------------------------------------------------------------------- */

function logout(opt)
{ infowindow.close() ; 
  if(opt&&!window.confirm(L.delwarn)) return ;
  var xhttp = new XMLHttpRequest() ;
  xhttp.onreadystatechange = function() 
  { 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 ; }
      updateacbtns(0) ; 
      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),opt=='account'?getbtnpos(9):0,opt) ;
     } ; 
  } ;
  function logoutfactory(ind) { return function() { logout(ind) ; } ; }

  if(prefs.email)
  { d.appendChild(genspan(prefs.email,'hr')) ; 
    function doprefs() 
    { infowindow.close() ; prefsprompt(L.prefs+':',null,null) ; }
    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)
  { updateacbtns(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(' ')
            }
  }
  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(buscolour)
{ var d , 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) ; 
  img.setAttribute('src',getbus(buscolour,"%23f7e9ce")) ; 
  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) ;

  img = cloneCanvas(btnicon(prefs.email?1:0).black) ; 
  d = domcreate('div',img) ; 
  d.setAttribute('style','position:absolute;top:0;right:2px;cursor:pointer') ; 
  d.onclick = function() 
              { if(infowindow.type=='login') infowindow.close() ; 
                else infowindow.open(acmenu('login'),0,'login') ; 
              } ;
  d.title = L.logindoc ; 
  body.appendChild(d) ;
  blurbdiv.im = img ; 
  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('eye','settings')) ; 
    tr.appendChild(textcell(L.eyedoc,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) ; 
  }
  /* ------------------------------------------------------------------------ */

  rmhelpdiv.im = cloneCanvas(btnicon(prefs.email?1:0).black) ;
  if(mode==1) tr = domcreate('tr',buttoncell('dl',-1,rmhelpdiv.im)) ; 
  else if(!mode) tr = domcreate('tr',buttoncell('dl',-1,rmhelpdiv.im)) ; 
  else 
  { td = domcreate('td',rmhelpdiv.im,'style','padding-bottom:4px') ; 
    tr = domcreate('tr',buttoncell(-1,'eye',rmhelpdiv.im)) ;
  }
  if(mode<0) tr.appendChild(textcell(L.logindoc,L.eyedoc)) ; 
  else tr.appendChild(textcell(L.save,L.logindoc)) ; 
  t.appendChild(tr) ; 

  if(!mode)
  { tr = domcreate('tr',domcreate('td','Keyboard: ','valign','top')) ; 
    td = document.createElement('td') ;
    d = document.createElement('div') ;
    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) ; 

    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.rte',L.exindex) ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;

    a = domcreate('span',domcreate('i','Routemasher')) ; 
    domadd(a,' '+L.masher) ; 
    a = domcreate('a',a,'href','resources/masher.html') ; 
    a.setAttribute('style','cursor:pointer;color:#0000bd;text-decoration:none') ; 
    d.appendChild(a) ; 
    d.appendChild(document.createElement('br')) ;
  }

  a = genlink('https://www.routemaster.app/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(segments[0].pts,ind) ; } ; } ;

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

  if(segments[0].level)
  { // functions to add:
    // (i)   sort routes by date and time (in a tour)
    // (ii)  update all routes
    // (iii) add/modify a description
    d.appendChild(titlediv('title',segments[0].title,1,0)) ;
    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&&!getalts.pending) 
  { d.appendChild(genspan(pluralise(L.noalts,p.nnull)+'  [')) ;
    if(!getalts.altthresh||getalts.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')) ;
  }
  
  // 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(addloadfactory("geotag"),L.geotag,'br')) ;
  dd.appendChild(genclickfn(dlfactory(1),L.saveidx,'br')) ; 

  // 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 swapsegfactory(ind) { return function() { swapseg(ind) ; } ; } ;

function seginfodiv(segs,segno)
{ var d = document.createElement('div') ;
  var props=segs[segno],span,prose,div,p,sty ; 

  d.appendChild(genspan(inject2(L.segment,segs[segno].colour+segno,
                        segs.length,segs[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(L.stats,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([segs[segno]]) ; 
  d = genldiv(p) ; 
  d.setAttribute('style','display:block') ; 
  div.appendChild(d) ; 

  d = document.createElement('div') ;
  if(segs.length>1) 
    d.appendChild(genclickfn(discard,caps(L.deletesegment),'br')) ; 
  else d.appendChild(genspan(caps(L.deletesegment),'br','color:silver')) ;
  if(segs[segno].pts.length) 
  { 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,segs[segno-1].colour,L.preceding),'br')) ;
    if(segno<segs.length-1) prose = 'br' ; else prose = 'hr' ;
    d.appendChild(genclickfn(combineb,
           inject2(L.combinewith,segs[segno-1].colour,L.preceding),prose)) ;
  }
  if(segno<segs.length-1)
  { d.appendChild(genclickfn(swapsegfactory(segno),
               inject2(L.swapwith,segs[segno+1].colour,L.following),'br')) ;
    d.appendChild(genclickfn(combinef,
            inject2(L.combinewith,segs[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==segs[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.segno,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.routemaster.app/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 = iconic.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]],-1) ;
      doim(s) ; 
      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) 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 eyemenu()
{ 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 ; 
  if(segments[0].type=='tour') ncol = 1 ; 
  else 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] ; 
  }      
  if(segments[0].type!='tour') 
    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(segments[0].pts,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')) ; 

      // date
      if(segments[0].type=='tour'&&route.date) 
        addcell(document.createTextNode(route.date)) ; 
    }
    t.appendChild(tr) ;
  }
  return t ; 
}
/* -------------------------------------------------------------------------- */

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

  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] ;
    if(props.ttype=='html') l = props.tlink ;
    else l = trackref(routeprops.origin,props.tlink,1) + '&mode=z' ;
    s += ' : <a href="' + l + '">' + 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.segno,s1=selected.ptno,s2=selected.type?selected.type:null ;
  var alt,s,x,lat,lng,time,c,ctx,grad,i,h,hh,tdist,tasc ; 
  var pts=s2?segments[s0].geo:segments[s0].pts , pt = pts[s1] ;
  var pos = pt.pos , npt=null , step , dd ;
  var s , dt , dh , nalt = null , d = document.createElement('div') ;
  var ratio = window.devicePixelRatio||1 , url = document.location.href ;

  i = url.indexOf('?') ;
  if(i>=0) url = url.substring(0,i) ; 

  if(!s2&&s1<segments[s0].pts.length-1) 
  { npt = segments[s0].pts[s1+1] ; step = pt.delta ; nalt = npt.h ; }

  if(!s2&&s1) for(h=pts[0].h,tasc=(h==null?null:0),tdist=i=0;i<s1;i++,h=hh)
  { tdist += pts[i].delta ;
    if(tasc!=null)
    { if((hh=pts[i+1].h)==null) tasc = null ; else if(hh>h) tasc += hh - 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
  if(!s2)
  { 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) ; 
    }

    // distance and ascent to point
    if(s1&&tdist>0)
    { s = domcreate('span',kmspan(tdist)) ;
      if(tasc!=null) 
      { domadd(s,domcreate('span',', ↑','style','padding-right:2px')) ; 
        domadd(s,prettynum(0.5+tasc,tasc>10000)) ;
        domadd(s,domcreate('span','m','style','padding-left:2px')) ; 
      }
      domadd(d,s) ; 
    }

    // 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%')) ;
  }
  else if(segments.length>1) 
  { s = inject(L.segpt,s0) ; d.appendChild(genspan(s,'hr','font-size:80%')) ; }

  // edit functions (delete, drag, transfer... )
  if(segments[s0].pts.length+segments[s0].geo.length>1) 
    d.appendChild(genclickfn(wpfactory(null),caps(L.wpdel),'br')) ; 
  else d.appendChild(genspan(caps(L.wpdel),'br','color:silver')) ;
  d.appendChild(genclickfn(draggit,L.mkdrag,'br')) ; 

  function wpfactory(ind) 
  { if(ind) return function() { inswp(ind) ; } ; 
    else if(ind==0) return function() { wpdel(1) ; } ; 
    else return function() { wpdel() ; } ; 
  } ;

  if(!s2)
  { d.appendChild(genclickfn(wpfactory(1),inject(L.dragaway,L.dragfor),'br')) ; 
    d.appendChild(genclickfn(wpfactory(-1),inject(L.dragaway,L.dragback),'br'));
  }

  d.appendChild(genclickfn(wpfactory(0),caps(s2?L.attach:L.detach),'br')) ;
  if(url.substring(url.length-8)=='test.php')
    d.appendChild(genclickfn(function(){console.log(pt);},'Log to console','br')) ;

  return d ; 
}
function kmspan(val) 
{ var s , pad = 'padding-right:2px' ; 
  if(val>4999) 
  { s = domcreate('span',diststring(val)) ; 
    domadd(s,domcreate('span','km','style','padding-left:2px')) ; 
  }
  else s = domcreate('span',prettynum(val+0.5,1,'m')) ; 
  return s ; 
}
/* -------------------------------------------------------------------------- */

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(!val) 
  { if(!editable) return d ; 
    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 ;
}
/* -------------------------------------------------------------------------- */

function textpromptcleanup() { partialcleanup() ; keyhandler = keystroke ; }

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 ;
}
function promptbtn(type,label,cancelfn)
{ var button = domcreate('input',null,'type','button') , sty = 'white' ; 
  button.setAttribute('value',label) ; 
  if(type=='submit') sty = '#e2e2e2' ; 
  if(type!='cancel') sty += ';margin-left:4px' ;
  button.setAttribute('style','background-color:'+sty) ; 
  button.onclick = cancelfn ;
  return button ;
}
/* -------------------------------------------------------------------------- */

function textprompt(msg,oldval,action,prompttype)
{ var w,h,l,t,defw,defh,i,box,s,img,div,bigdiv,ip,span,style,c,d,i ;
  var bdiv=(mapparent?mapparent:body) , tab,tr,td,lsty,returnkey,cancelfn ; 
  keyhandler = null ;

  if(prompttype=='desc') { defw = 500 ; defh = 240 ; }
  else if(prompttype=='loadwait') { defw = 320 ; defh = 20 ; }
  else if(prompttype=='optim') { defw = 100 ; defh = 60 ; }

  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=='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 
  { 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 optimfactory(prompttype,box,action,oldval)
  { return function()
    { textpromptcleanup() ; 
      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) ; 
    }
  }
  function submitfactory(action,ip)
  { return function() { textpromptcleanup() ; action(ip.value) ; } }

  if(prompttype=='optim') 
    returnkey = optimfactory(prompttype,box,action,oldval) ;
  else returnkey = submitfactory(action,ip) ;

  div.appendChild(ip) ; 
  
  if(prompttype!='desc') ip.onkeydown = function(e)
  { if(e.keyCode==13||e.which==13) 
    { e.preventDefault() ; returnkey() ; } 
  }

  // cancel
  function cancelfactory(prompttype,action)
  { return function() 
    { textpromptcleanup() ; if(prompttype=='optim') action('cancel') ; }
  }
  cancelfn = cancelfactory(prompttype,action) ;
  span = domcreate('span',promptbtn('cancel',caps(L.cancel),cancelfn)) ; 

  // submit
  span.appendChild(promptbtn('submit',L.ok,returnkey)) ;
  span.setAttribute('style','margin:auto;display:table') ; 
  div.appendChild(span) ; 

  // doc link
  if(prompttype=='desc') div.appendChild(doclink('desc',L.descdoc)) ; 
  bdiv.appendChild(div) ; 
}
/* -------------------------------------------------------------------------- */

function prefsprompt(msg,oldval,action)
{ var w,h,l,t,defw,defh,i,box,s,img,div,bigdiv,ip,span,style,c,d,i ;
  var ctx , cty , bdiv=(mapparent?mapparent:body) , detail,auto,precdiv ;
  var canvasdiv , lang , langs=['en','fr'] , flagdivs = [0,0] , returnkey ;
  var tab,tr,td,lsty,email,pclick,deffn ; 
  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] ;
  keyhandler = null ;

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

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

  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:defprefs.detail) ; 
  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) 
    prefsprompt.precision = prefs.precision ; 
  else prefsprompt.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==prefsprompt.precision?'grey':'white') ; 
    precdiv.appendChild(s) ;
  }
  function pclickfactory(precdiv,unitdivs)
  { return function(e)
    { var i,prec ; 
      if(e) i = e.clientX - precdiv.getBoundingClientRect().left ; else i = 0 ; 
      prec = Math.floor(i/31) ;
      if(prec==prefsprompt.precision) return ; 
      unitdivs[prefsprompt.precision].style.borderColor = 'white' ; 
      prefsprompt.precision = prec ; 
      unitdivs[prec].style.borderColor = 'grey' ; 
    } ;
  }
  pclick = pclickfactory(precdiv,unitdivs) ;
  precdiv.onclick = pclick ;
  tr.appendChild(domcreate('td',precdiv)) ; 
  tab.appendChild(tr) ; 
  ip.appendChild(tab) ; 

  // handle submit/return key
  function submitfactory(email,lingo,detail,auto,box)
  { return function()
    { textpromptcleanup() ; 
      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(lingo) ; 
      prefs.detail = detail.value ;
      prefs.optim = auto.checked?1:0 ;
      prefs.maxsep = box.checked?100:0 ;
      prefs.precision = prefsprompt.precision ;
      xhttp.open("GET","resources/signup.php?opts="+email.value+'&'+
                 prefs.lang+'&'+prefs.detail+'&'+prefs.optim+'&'+
                 prefs.maxsep+'&'+prefs.precision) ;
      xhttp.send() ;
    }
  }
  returnkey = submitfactory(email,langs[lang],detail,auto,box) ;
  div.appendChild(ip) ; 
  
  ip.onkeydown = function(e)
  { if(e.keyCode==13||e.which==13) { e.preventDefault() ; returnkey() ; } }

  // cancel
  span = domcreate('span',promptbtn('cancel',caps(L.cancel),textpromptcleanup)) ; 
  span.setAttribute('style','margin:auto;display:table') ; 

  // default
  function defaultfactory(unitdivs,auto,box,detail,pclick)
  { return function()
    { auto.checked = defprefs.optim ; 
      box.checked = defprefs.maxsep ; 
      pclick() ; 
      detail.value = defprefs.detail ; 
    }
  }
  deffn = defaultfactory(unitdivs,auto,box,detail,pclick) ;
  span.appendChild(promptbtn('defaults',L.defaults,deffn)) ; 

  // submit
  span.appendChild(promptbtn('submit',L.ok,returnkey)) ; 
  div.appendChild(span) ; 

  bdiv.appendChild(div) ; 
}
/* -------------------------------------------------------------------------- */

function labelmenu(msg,oldval,action,oldlabno)
{ var w,h,l,t,defw,defh,labelno,temp,i,missing=(oldlabno<0) ; 
  var div,bigdiv,ip,span,button,style,svg,labicon,lsvg,labname ;
  var delopt=null , bdiv=(mapparent?mapparent:body) ;
  var nlabel = iconic.names.length , btnlist = new Array(nlabel) ;
  var svgns = "http://www.w3.org/2000/svg" ;  

  keyhandler = null ; 
  if(missing||!oldlabno) oldlabno = 0 ; 
  labelmenu.thislabno = oldlabno ; 

  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==labelmenu.thislabno) return ; 
    while(labname.childNodes.length) 
      labname.removeChild(labname.childNodes[0]) ; 
    domadd(labname,L.labels[labelno]) ;
    temp = drawicon(labelno) ;
    lsvg.replaceChild(temp,labicon) ;
    btnlist[labelmenu.thislabno].setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:white') ;
    btnlist[labelno].setAttributeNS(null,'style',
                'stroke:black;stroke-width:1;fill:#e2e2e2') ;
    labicon = temp ; 
    labelmenu.thislabno = labelno ;
    ip.focus() ; 
    ip.setSelectionRange(ip.value.length,ip.value.length) ; 
  }
  function iconfactory(labelno) { return function() { iconsel(labelno) ; } }

  defw = 41*8-2 ; 
  defh = 132+45 ; 

  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') ; 
  div.appendChild(document.createElement('br')) ; 

  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(oldlabno) ; // oldlabno 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[oldlabno]) ;
  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==oldlabno) ;
    btnlist[labelno] = temp[1] ;
    button.appendChild(temp[0]) ; 
    button.onclick = iconfactory(labelno) ; 
    div.appendChild(button) ; 
  }
  /* ------------------------------------------------------------------------ */

  // handle submit/return key
  function submitfn(delopt,ip)
  { return function() 
    { textpromptcleanup() ; action(delopt?null:ip.value,labelmenu.thislabno) ; } 
  }
  
  // cancel
  span = domcreate('span',promptbtn('cancel',caps(L.cancel),textpromptcleanup)) ; 
  span.setAttribute('style','margin:auto;display:table') ; 

  // delete
  if(!missing) span.appendChild(promptbtn('del',caps(L.del),submitfn(1,ip))) ; 

  // submit
  span.appendChild(promptbtn('submit',L.ok,submitfn(0,ip))) ; 
  ip.onkeydown = function(e) { if(e.keyCode==13) submitfn(0,ip)() ; }
  div.appendChild(span) ; 

  // doc link
  div.appendChild(doclink('labels',L.labeldoc)) ; 
  bdiv.appendChild(div) ; 
  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 ; 
}
/* -------------------------------------------------------------------------- */

function drawxcur(pro,sel)
{ var pos , i , linewid , inc , cx, cy , jlim , div , r=pro.radius , w , h ; 
  for(div=pro.curdiv;div.childNodes.length>0;) 
    div.removeChild(div.childNodes[div.childNodes.length-1]) ;
  if(sel.type||!pro.m.h.length) return ; 

  pos = pro.m.wp2pro[sel.segno][sel.ptno] ;
  pro.sel = sel ; 
  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() ; 
}
/* -------------------------------------------------------------------------- */

function toggleprofile(e)
{ var flag , sel , pos = window.innerWidth - e.clientX ; 
  var pro=toggleprofile.pcopy,segments=toggleprofile.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 ; drawprofile() ; 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.segno,sel.ptno,sel.type,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) ;
}
/* -------------------------------------------------------------------------- */

function telltale(msg) 
{ zonktale() ; 
  var sty = 'background:white;position:absolute;'+
            'left:0;bottom:0;padding:3px;z-index:999;font-size:150%' ; 
  telltale.div = domcreate('div',msg,'style',sty) ;
  telltale.opacity = 10 ; 
  telltale.div.setAttribute('style',sty) ; 
  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' , 
  eyemenu:'List routes' , 
  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:'Waymark' ,
  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' , 
  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 to save as a track.' , 
  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 in .fit or .tcx' ,
  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 %' ,
  deletex:'delete' ,
  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 point' ,
  insgeo:'geolocate image' ,
  wpdrag:'drag point' ,
  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' ,
  bulkundo:'Bulk undo' ,
  bulkredo:'Bulk 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.\n“FIT File Viewer” may be able to patch it up.' ,
  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 turn instructions;' ,
          'Add photos;' ,
          'Adjust altitudes manually or using the Google elevations service;' ,
          'View an altitude profile;' ,
          'Organise routes in tours, indexes and metaindexes;' ,
          'Visualise the route in full screen;' ,
          'Undo and redo editing operations;' ,
          'Save as tcx/gpx/fit/rte.' ] ,
  eyedoc:'show routes as a table' ,
  setsave:'settings' ,
  unre:'undo/redo' ,
  logindoc:'register/login/prefs/logout' ,
  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' ,
  detach:'detach point' ,
  attach:'convert to waypoint' ,
  tabdoc:'[tab] inserts %;' ,
  stabdoc:'[shift tab] inserts a draggable point backwards;' ,
  deldoc:'[del], [backspace] = delete point;' ,
  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.' ,
  masher:'tool for offline processing' ,
  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 waymark','% waymarks'] ,
  remove:'Remove' ,
  numphotos:['1 photo','% photos'] ,
  numsegments:['1 segment','% segments'] ,
  combine:'Combine' ,
  sepwarn:'Note that separations >100m may cause problems on Garmin' ,
  loadnewroute:'Load new route' ,
  loadnewseg:'add route as new segment' ,
  geotag:'Position geotagged image' ,
  saveidx:'Save track as route index' ,
  combinetosave:'Combine segments to save in other formats' ,
  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' ,
  geolocdoc:'Documentation for geolocating images' ,
  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/RTE/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: ' ,
  badjpg:'Invalid jpg file: ' ,
  notgeotagged:'No geotag in jpg file: ' ,
  distance: 'Distance' ,
  altitude: 'altitude' ,

  // subsequent additions
  illegalfield:'Illegal % field' ,
  select:'Select TCX/GPX/RTE/KML/CSV/FIT file(s): ' , 
  selectjpg:'Select JPG file: ' , 
  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' ,
  outoforder: 'Your points are out of order, so turn instructions cannot be placed.',
  notcxindex:'tcx indexes are no longer supported by routemaster – use .rte instead.' ,
  multitrack:'multitrack route: only the first track will be retained.' , 
  swipedown: 'Swipe down: ' ,
  returnkey: 'Return key: ' ,
  backtogps: 'back to GPS'
} ;

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' , 
  eyemenu:'Lister les itinéraires' , 
  controlmenu:'Menu principal' , 
  routeprops:'Attributs de l’itinéraire' , 
  segmentprops:'Attributs du segment' , 
  waypointprops:'Attributs du point' , 
  atwaypoint:'au point actuel' , 
  nosplitsegment:'[désactivé au premier point d’un segment]' , 
  labelwaypoint:'Balise' ,
  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' , 
  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 afin de les enregistrer en tant que trace.' , 
  untitled:'Sans titre' , 
  saveindexas:'Enregistrer %index en tant que' , 
  saveXas:'Enregistrer % en tant que' , 
  saveas:'Enregistrer en tant que' , 
  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 en .fit ou .tcx' ,
  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 %' ,
  deletex:'supprimer' ,
  deletesegment:'supprimer le segment' ,
  deleteroute:'supprimer l’itinéraire' ,
  deleteindex:'supprimer l’index' ,
  splitsegment:'diviser le segment' ,
  xferwaypoint:'transferrer le point' ,
  dellabel:'supprimer l’étiquette' ,
  labelpt:'étiqueter le point' ,
  editlabel:'modifier le point' ,
  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' ,
  wpins:'insérer un point' ,
  insgeo:'géolocaliser image' ,
  wpdrag:'faire glisser le point' ,
  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' ,
  bulkundo:'Annuler en masse' ,
  bulkredo:'Rétablir en masse' ,

  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' ,
  missfit:'Définition manquante dans le fichier FIT.\nPeut-être que “FIT File Viewer” le refistolerait' ,
  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 ;' ,
          '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 (par exemple, ‘Virage à G’) ;' ,
          'Ajouter des photos ;' ,
          'Ajuster les altitudes manuellement ou en utilisant le service d’altitudes Google ;' ,
          'Afficher un profil d’altitude ;' ,
          'Organiser les itinéraires en circuits, index et métaindex ;' ,
          'Afficher l’itinéraire en plein écran ;' ,
          'Annuler et rétablir les modifications ;' ,
          'Sauvegarder en fichier tcx/gpx/fit/rte.' ] ,
  eyedoc:'vue d’ensemble' ,
  setsave:'contrôles' ,
  unre:'annuler/rétablir' ,
  logindoc:'s’abonner/connexion/prefs/déconnexion' ,
  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 / 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 glissable' ,
  dragback:'vers l’arrière un point glissable' ,
  detach:'détacher le point' ,
  attach:'convertir en point de passage' ,
  tabdoc:'[tab] insèrer % ;' ,
  stabdoc:'[↑ tab] insèrer % ;' ,
  deldoc:'[suppr], [retour arrière] = supprimer le point ;' ,
  sdeldoc:'[↑ suppr], [↑ retour arrière] = supprimer le segment.' ,
  mouse:'Souris :' ,
  clickdoc:'[clique] sélectionne le point le plus proche de la position du curseur;' ,
  sclickdoc:'[↑ clique] étend le segment actuel à la position du curseur.' ,
  masher:'outil hors ligne' ,
  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 en tant que 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 une 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 balise','% balises'] ,
  remove:'Supprimer' ,
  numphotos:['1 photo','% photos'] ,
  numsegments:['1 segment','% segments'] ,
  combine:'Fusionner' ,
  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' ,
  geotag:'Localiser image géotaguée' ,
  saveidx:'Sauvegarder la trace en tant que index des itinéraires' ,
  combinetosave:'Combiner les segments pour sauvegarder en autres formats' ,
  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.)' ,
  geolocdoc:'Documentation pour la géolocation des images (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/RTE 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' ,
  badtrack:'URL de trace non valide (doit être .gpx, .tcx, .rte, .kml ou .fit)' ,
  badopt:'Option non reconnue: ' ,
  badjpg:'Fichier jpg invalide : ' ,
  notgeotagged:'Fichier jpg sans géotagging : ' ,
  distance: 'Distance' ,
  altitude: 'altitude' ,

  // subsequent additions
  illegalfield:'Champ % illégal' ,
  select:'Sélectionnez un ou plusieurs fichiers TCX/GPX/RTE/KML/FIT : ' , 
  selectjpg:'Sélectionnez un fichier JPG : ' , 
  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 supplémentaires ont été interpolés.' ,
  formatdoc:'Documentation pour les formats de trace (angl.)' ,
  cantplace:'Impossible de placer un point de passage : ' ,
  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' ,
  notcxindex:'Les index tcx ne sont plus supportés par routemaster – plutôt utiliser .rte.' ,
  multitrack:'Itinéraire multitrace : seule la première trace sera conservée.' ,
  swipedown: 'Glisser vers le bas : ' ,
  returnkey: 'Touche retour : ' ,
  backtogps: 'retour à GPS'
} ;

• getbus     • domadd     • domcreate     • prettynum     • shortenname     • abbreviate     • underline     • clone     • isvaliddate     • dist     • angle     • midpt     • normalise     • xmlfloat     • dehexify     • numerate     • pttype     • routetype     • function     • addlabel     • getlatlong     • deltify     • recurse     • flipseg     • getstats     • getmetastats     • diststring     • kmstring     • prettystats     • routestats     • flatten     • squash     • genclickfn     • genspan     • gentimes     • filedialogue     • unspace     • parsestats     • getprops     • gettags     • getpt     • readcsv     • readkml     • readtcx     • readgpx     • getrtept     • getrteprops     • readrte     • readfitvalue     • readfitangle     • readfit     • maketext     • getgallery     • gentag     • gentypedtag     • gendesc     • genindex     • formatstats     • genimgtag     • genurl     • genoptim     • geninfotag     • tcxpos     • gpxp     • decimate     • optimise     • readgps     • writegps     • writerteprops     • writegpx     • writerte     • writetcx     • writefit     • fitinject     • fitinject2     • fitinject4     • fitinjectangle     • checksum     • profilemaptype     • profiletype     • drawpro     • choosearrows     • geotag     • getexifgps     • readexiftag     • getexifstring     • catch     • BlobBuilder     • Blob     • separator     • parse     • toDms     • toLat     • toLon     • toBrng     • fromLocale     • toLocale     • compassPoint     • wrap90     • wrap180     • wrap360     • Vector3d     • LatLon     • Utm     • OsGridRef     • constructor     • lat     • latitude     • lon     • lng     • longitude     • parse     • distanceTo     • initialBearingTo     • equals

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* routemaster interface and utility functions  (c) Colin Champion 2014-2026  */
/*                                                               MIT Licence  */
/* www.routemaster.app/software/routemaster.html                              */
/* This file also contains code by Eli Grey and Chris Veness, issued under    */
/* the same licence, but with copyright vested in them rather than me.        */
/* See below for details.                                                     */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

var rmdoc = '<!-- https://www.routemaster.app/software/routemaster.html -->\n' ;

function getbus(col1,col2)
{ var i , j , s , c , svgbus = 
  'data:image/svg+xml;charset=utf-8,'+
   '<svg xmlns="http://www.w3.org/2000/svg" '+
  'xmlns:xlink="http://www.w3.org/1999/xlink" width="32" height="32">'+
  '<title>bus</title>'+
  '<path d="M 0 29  L 32 29  L 32 20  L 3 20  A 3 3 0 0 0 0 23 Z" '+
        'style="stroke-width:0" fill="@1"/>'+
  '<path d="M 2 13  L 32 13  L 32 0  L 8 0  A 6 6 0 0 0 2 6  L 2 11  A 2 2 0 0 0 4 13 z" '+
        'style="stroke-width:0" fill="@1"/>'+
  '<path d="M 5 7  L 32 7  L 32 2  L 9 2  A 4 4 0 0 0 5 6 z" '+
        'style="stroke-width:0" fill="white"/>'+
  '<path d="M 4 11 A 1 1 0 0 0 5 12  L 32 12  L 32 8  L 5 8  A 1 1 0 0 0 4 9 z" '+
        'style="stroke-width:0" fill="@2"/>'+
  '<circle r="5" cx="6" cy="27" style="stroke-width:0" fill="black"/>'+
  '<circle r="2.5" cx="6" cy="27" style="stroke-width:0" fill="%23f7e9ce"/>'+
  '<line x1="5.5" y1="13" x2="5.5" y2="20" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="8" y1="13" x2="8" y2="20" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="13.75" y1="13" x2="13.75" y2="20" style="stroke:@1;stroke-width:2.5"/>'+
  '<line x1="21.5" y1="13" x2="21.5" y2="20" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="15" y1="15.5" x2="32" y2="15.5" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="6" y1="18.5" x2="8" y2="18.5" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="4" y1="3.5" x2="32" y2="3.5" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="11.5" y1="1" x2="11.5" y2="7" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="19.5" y1="1" x2="19.5" y2="7" style="stroke:@1;stroke-width:1"/>'+
  '<line x1="27.5" y1="1" x2="27.5" y2="7" style="stroke:@1;stroke-width:1"/></svg>' ;

  for(s='',i=0;;i=j+2)
  { j = svgbus.indexOf('@',i) ; 
    if(j<0) return s + svgbus.substring(i) ;
    c = svgbus.charAt(j+1) ; 
    if(c=='1') c = col1 ; else c = col2 ; 
    s += svgbus.substring(i,j) + c ;
  }
}
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) // opt means “put a space in long numbers”
{ 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 underline(d) 
{ d.setAttribute('style',
      'margin-bottom:2px;border-bottom:solid 1px silver;padding-bottom:2px') ; 
  return d ; 
}
function clone(x) { var i,y={} ; for(i in x) y[i] = x[i] ; return y ; }

function isvaliddate(d) 
{ if(!d) return false ; 
  if(Object.prototype.toString.call(d)!=="[object Date]") return false ;
  else return !isNaN(d.getTime()) ;
}
function dist(x,y)
{ x = new LatLonSpherical(x.lat,x.lng) ; 
  y = new LatLonSpherical(y.lat,y.lng) ; 
  return x.distanceTo(y) ; 
}
function angle(x,y)
{ x = new LatLonSpherical(x.lat,x.lng) ; 
  y = new LatLonSpherical(y.lat,y.lng) ; 
  return x.initialBearingTo(y) * Math.PI/180 ; 
}
function midpt(x,y) // should deal with wraparound
{ return { lat:(x.lat+y.lat)/2 , lng:(x.lng+y.lng)/2 } ; }

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 xmlfloat(x) { return parseFloat(x.textContent) ; }

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 ;
}
var defparms  = {tol:15,maxsep:100,wppenalty:700,vweight:1} ;

var iconic = 
{ 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 ] ,
} ; 
/* ------------------------------- 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 = this.delta = this.type = this.point = null ; 
  if(isvaliddate(t)) this.t = t ; else this.t = null ; 
  this.photo = [] ;
  this.caption = '' ; 
  this.clickhandler = this.photohandler = null ; 
}
/* ------------------------------ props structure --------------------------- */

function routetype(type)
{ 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.ttype = null ; 
  this.colour = this.hue = "#ff0000" ; 
  this.photo = [] ;
  this.smallphoto = [] ;
  this.pts = [] ; 
  this.geo = [] ;
  this.shades = [] ;
  if(type=='metaindex') { this.type = type ; this.level = 2 ; }
  else if(type=='index'||type=='tour'||type=='segments') 
  { this.type = type ; this.level = 1 ; }
  else { this.type = null ; this.level = 0 ; }
}
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 ; 
}
// addlabel is used for a labelled point attached to a gpx/tcx track 

function addlabel(pts,pt) 
{ var i,mindist,near ; 
  for(near=null,i=0;i<pts.length;i++) if(!near||dist(pt.pos,pts[i].pos)<mindist) 
  { mindist = dist(pt.pos,pts[i].pos) ; near = pts[i] ; } 
  if(near) near.setlabel(pt.label,pt.caption) ;
}
/* -------------------------------------------------------------------------- */

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 { lat:lat , lng:lon } ; 
}
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 , pts = seg.pts , len = pts.length , geo = seg.geo , gen = geo.length ; 
  var 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||pts[i].pos.lat>parms.pos.lat) parms.pos = pts[i].pos ; }
      for(i=0;i<gen;i++) 
      { if(!parms.pos||geo[i].pos.lat>parms.pos.lat) parms.pos = geo[i].pos ; }
    }
    else if(opt=='draw4') draw(seg,4,1) ; 
    else if(opt=='draw') draw(seg) ; 
    else if(opt=='geodraw') 
      for(i=0;i<seg.geo.length;i++) geodraw(seg.geo[i],seg.colour) ; 
    else if(opt=='setmap') 
    { for(i=0;i<len;i++) pts[i].setmap(parms.map,parms.sel) ; 
      for(i=0;i<gen;i++) geo[i].setmap(parms.map,parms.sel) ; 
    }
    else if(opt=='genmarker') 
    { for(i=0;i<len;i++) 
        if(pts[i].photo.length) genmarker(pts[i],0,parms.map,parms.sel) ; 
      for(i=0;i<gen;i++) 
        if(geo[i].photo.length) genmarker(geo[i],0,parms.map,parms.sel) ; 
    }
    else if(opt=='genlabel') 
    { for(i=0;i<len;i++) 
        if(pts[i].label) pts[i].setlabelmap(parms.map,parms.sel) ; 
      for(i=0;i<gen;i++) 
        if(geo[i].label) geo[i].setlabelmap(parms.map,parms.sel) ;  
    }
    else if(opt=='undraw') undraw(seg) ; 
    else if(opt=='deltas') deltify(pts) ; 
    else if(opt=='getbounds') segbounds(parms,seg) ; 
    else if(opt=='listsegs') parms.push(dsseg(pts)) ; 
    else if(opt=='colour') { seg.hue = parms ; seg.colour = col ; }
    else if(opt=='obliterate') 
    { for(i=0;i<len;i++) pts[i].setmap(null,null) ; 
      for(i=0;i<gen;i++) geo[i].setmap(null,null) ; 
      undraw(seg) ; 
    }
    else alert(opt+' is an unrecognised option') ; 
  }
  else for(i=0;i<len;i++) recurse(pts[i],opt,parms) ; 
}
function flipseg(pts)
{ var i,j,x,len=pts.length,lim ;

  for(lim=Math.floor(len/2),i=0;i<lim;i++)
  { j = (len-1)-i ; x = pts[i] ; pts[i] = pts[j] ; pts[j] = x ; }
  for(i=0;i<len-1;i++) pts[i].delta = pts[i+1].delta ; 
  pts[len-1].delta = null ; 
}
/* -------------------------------- 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 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 prettystats(title,stats,opt) // opt requests a closing full stop
{ var div = domcreate('div') , s ; 
  if(title) { domadd(div,domcreate('b',title)) ; 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,opt?'m.':'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 = 'k' ;
    }
    else 
    { s = domcreate('span',stats[1].toFixed(0),'style','padding-right:1.5px') ;
      div.appendChild(s) ;
      s = '' ; 
    }
    if(!stats[2])
    { s += (opt?'m.':'m') ;
      div.appendChild(domcreate('span',s)) ; 
      return div ; 
    }
    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,opt?'m.':'m') ; 
  }
  return div ; 
}

// 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 , npts:nowpts   , maxsep , nlabels , 
           pix:photo   , ntimes , maxalt , minalt ,
           date
         }
}
/* --------------------------------- flatten -------------------------------- */

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

  for(d=npoint=s0=0;s0<segments.length;opos=pos,s0++)
    for(iter=0;iter<(geoopt?2:1);iter++)
  { s2 = iter?'geo':null ; 
    pts = iter?segments[s0].geo:segments[s0].pts ;
    for(s1=0;s1<pts.length;s1++) 
    { pos = pts[s1].pos ;
      if(geoopt) d = null ; 
      else if(s1>0) d += pts[s1-1].delta ; 
      else if(npoint) d += dist(opos,pos) ;
      p[npoint++] = { pos , h:pts[s1].h , t:pts[s1].t , 
                      sel:{segno:s0,ptno:s1,type:s2} , d } ;
    }
  }
  return p ; 
}
/* -------------------------------------------------------------------------- */

// 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(nh=h=0,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 ;
      }
      if(pts[k].h&&pt.h==null) pt.h = pts[k].h ; 
      if(pts[k].t&&!pt.t) pt.t = pts[k].t ; 
    }
    npts[j++] = pt ; 
  }
  npts.length = j ; 
  return npts ;
}
/* -------------------------------------------------------------------------- */

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 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 gentimes(ipts) 
{ var maxspeed=3600.0/50 ; 
  var i,j,k,date,tlast,tdist,ttime,flag,i,otime,sep,di,dk,h,seen ; 
  var msecs = new Array(ipts.length) , distance = new Array(ipts.length) ;

  for(seen=date=tlast=null,tdist=ttime=i=flag=0;i<ipts.length;otime=time,i++) 
  { if(ipts[i].t) time = ipts[i].t ; else time = null ; 
    if(time) 
    { if(!date) date = time.toDateString() ; 
      msecs[i] = time = time.getTime() ; 
      seen = 1 ; 
    } 
    else msecs[i] = null ; 
    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 ; 
  }

  // fill in missing times
  if(seen==0||tdist==0||flag!=0) 
    for(msecs[0]=0,hspeed=3600.0/10,vspeed=3600/0.4,i=1;i<ipts.length;i++) 
  { h = ipts[i-1].delta*hspeed ;
    if(ipts[i-1].h!=null&&ipts[i].h!=null) 
      h += (ipts[i].h-ipts[i-1].h)*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<ipts.length;i=k)
  { for(;i<ipts.length&&ipts[i].t!=null;i++) ;      // advance to null
    if(i==ipts.length) break ;
    for(k=i+1;k<ipts.length&&ipts[k].t==null;k++) ; // advance to non-null
    if(i==0) for(time=msecs[k],j=i;j<k;j++)
      msecs[j] = time - (distance[k]-distance[j])*ttime/tdist ;
    else if(k==ipts.length) for(time=msecs[i-1],j=i;j<ipts.length;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) ;
  }
  return { t:msecs , d:distance } 
}
/* ----------------------------- file dialogue ------------------------------ */

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

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

  // file browser
  if(ovr=="list") domadd(lab,L.loadlist+': ') ;
  else 
  { if(ovr=='geotag') domadd(para,L.selectjpg) ; else domadd(para,L.select) ;
    input = domcreate('input',null,'type','file') ; 
    if(ovr=='geotag') input.setAttribute('accept','.jpg') ; 
    else input.setAttribute('accept','.tcx,.gpx,.kml,.fit,.rte,.csv') ; 
    if(ovr!='conv') input.setAttribute('multiple','multiple') ; 
    input.addEventListener('change',function(e)
    { if(ovr!='conv') infowindow.close() ; 
      var filename , fileno , readers = new Array(input.files.length) ; 
      if(ovr!='geotag') 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(n=input.files.length,fileno=0;fileno<input.files.length;fileno++)
      { readers[fileno] = new FileReader() ;
        filename = input.files[fileno].name ;
        function readerfactory(i) 
        { if(ovr=='geotag') return function(e)
          { n -= 1 ; 
            var pos = geotag(readers[i].result,input.files[i].name) ; 
            if(pos) insgeo(pos,filename.substring(0,filename.length-4),n) ;
          }
          else return function(e)
          { render(readers[i].result,input.files[i].name,'file') ; } 
        }
        readers[fileno].onload = readerfactory(fileno) ; 
        extn = filename.substring(filename.length-4).toLowerCase() ;
        if(extn=='.fit'||extn=='.jpg')
          readers[fileno].readAsArrayBuffer(input.files[fileno]) ;
        else readers[fileno].readAsText(input.files[fileno]) ;
      }
    } ) ;
    para.appendChild(input) ; 
    div.appendChild(para) ; 
    if(ovr=='geotag') 
    { div.appendChild(doclink('geoloc',L.geolocdoc)) ; return div ; }
    else if(ovr=='conv') return div ; 
    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.carriedover) 
    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 ; 
    if(hits) n = hits.length ; else n = 0 ; 
    for(list=null,i=-1;i<n;i++)
    { if(i<0) 
      { if(ovr=='list') hit = filedialogue.pixerr ; else hit = gpserr ; 
        if(!hit) continue ; 
      }
      opt = document.createElement('option') ; 
      opt.value = i<0?hit:hits[i] ; 
      if(!list) list = domcreate('datalist',null,'id','dlist') ; 
      list.appendChild(opt) ; 
    }
    if(list)
    { 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=='geotag') { alert(this.value) ; return ; }
      if(ovr=='list') 
      { if(imginfo.carriedover&&this.value==imginfo.uri) 
        { imginfo.carriedover = null ; 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.routemaster.app/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 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 ,tok ;

  if(stats.charAt(0)>=0&&stats.charAt(0)<=9) // "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('↑') ; //         ^
    if(k>=0) 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('↑') ;        //         ^
    if(k<0) return r ; 
    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 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 ; 
}
/* -------------------------------- 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 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.mode=='gpx')
  { lat = parseFloat(node.getAttribute('lat')) ; 
    lon = parseFloat(node.getAttribute('lon')) ; 
    if(lat&&lon) pos = { lat:lat , lng:lon } ;
  }
  nodes = node.childNodes ;

  for(validalt=valid=1,nodeno=0;nodeno<nodes.length;nodeno++)
  { node = nodes[nodeno] ;
    if(node.nodeName==tags.el) alt = xmlfloat(node) ; 
    else if(node.nodeName==tags.time) // '1970-01-01T03:040:08Z'
      time = new Date(normalise(node.textContent)) ;
    else if(node.nodeName=='Position'&&tags.mode=='tcx') 
      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.photo = photo ; 
  if(truelabel) pt.label = truelabel ; 
  else if(label) pt.label = label ; 
  else if(caption) pt.label = 'Generic' ; 
  pt.caption = caption ; 
  return pt ;
}
/* -------------------------------------------------------------------------- */

// 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] = { lat:x[2*i] , lng:x[2*i+1] } ; 
  return p ;
}
/* -------------------------------------------------------------------------- */

function readkml(xmldoc,xmlfile)
{ var xmlcoords,xmlpts,i,lat,lon,h,route=new routetype('segments'),p,layerno ; 

  // get the route title
  xmlcoords = xmldoc.getElementsByTagName('name') ;
  if(xmlcoords.length>0&&xmlcoords[0].childNodes.length>0) 
    route.title = normalise(xmlcoords[0].childNodes[0].textContent) ;

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

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

function readtcx(xmldoc,xmlfile)
{ var nodeno,i,j,k,node,nsegment,course,r,nodes,photo=[] ;
  var tags = new gettags('tcx') , loadtype = 0 , coursepts , flag ; 
  var rp = new routetype('segments') ; 

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

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

  // loop over courses 
  course = xmldoc.getElementsByTagName(course[0].parentNode.nodeName) ;
  nsegment = course.length ;
  rp.pts = new Array(nsegment) ; 
  for(i=0;i<nsegment;i++) rp.pts[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)
        rp.pts[i] = getprops(course[i].childNodes[j].childNodes,tags,xmlfile) ; 
    if(rp.pts[i].indexmode) loadtype = rp.pts[i].indexmode ;
    else if(!loadtype&&rp.pts[i].stats) loadtype = 1 ; 
    if(loadtype) { alert(L.notcxindex) ; throw '' ; }
    rp.pts[i].pts = new Array() ;

    // get title and trackpoints
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='Name')
    { rp.pts[i].title = normalise(course[i].childNodes[j].textContent) ; 
      if(rp.title==null) rp.title = rp.pts[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)))  
          rp.pts[i].pts.push(r) ; 
      }
    }

    // get coursepoints
    coursepts = new Array() ; 
    for(j=0;j<course[i].childNodes.length;j++)
      if(course[i].childNodes[j].nodeName=='CoursePoint')
        if((r=getpt(course[i].childNodes[j],tags))) coursepts.push(r) ; 

    // are the waypoints in order? 
    if(isvaliddate(rp.pts[i].pts[0].t)) flag = 0 ; else flag = 1 ; 
    for(j=1;flag==0&&j<rp.pts[i].pts.length;j++) 
      if( !isvaliddate(rp.pts[i].pts[j].t) 
       || rp.pts[i].pts[j].t.getTime()<rp.pts[i].pts[j-1].t.getTime() ) 
        flag = 1 ; 

    if(flag==0) // j.e. if all points have times and the times are in order
    { // put the course points in position
      for(k=j=0;j<coursepts.length;j++)
      { if(isvaliddate(coursepts[j].t)) 
        { coursepts[j].setlabel(coursepts[j].label,coursepts[j].caption) ; 
          rp.pts[i].pts.push(coursepts[j]) ; 
          flag = -1 ; 
        }
        else coursepts[k++] = coursepts[j] ; 
      }
      coursepts.length = k ; 
      if(flag<0) 
        rp.pts[i].pts.sort(function(a,b){return a.t.getTime()-b.t.getTime();}) ;
    }

    // if any are left over (no time or waypoints not in order) use addlabel
    for(j=0;j<coursepts.length;j++) addlabel(rp.pts[i].pts,coursepts[j]) ; 

    // merge coincident points
    rp.pts[i].pts = squash(rp.pts[i].pts) ; // squeeze out coincident points
  }
  return rp ; 
}
/* -------------------------------------------------------------------------- */

function readgpx(xmldoc,xmlfile)
{ var xmlcoords,i,r,rp=new routetype() , tags = new gettags('gpx') ;
  // 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(i=0;i<xmlcoords.length;i++)
    if((r=getpt(xmlcoords[i],tags))) 
  { rp.pts.push(r) ; 
    if(r.label) rp.pts[rp.pts.length-1].setlabel(r.label,r.caption) ; 
  }
  rp.pts = squash(rp.pts) ; // squeeze out coincident points

  // 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(rp.pts,r) ; 

  // 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 getrtept(node)
{ var lat,lon,alt,time,nodeno,nodes,pt,ty ;
  var pos=null,caption=null,label=null,dt=null ; 

  lat = parseFloat(node.getAttribute('lat')) ; 
  lon = parseFloat(node.getAttribute('lon')) ; 
  if(lat&&lon) pos = { lat:lat , lng:lon } ; else return null ; 
  nodes = node.childNodes ;

  if(alt=node.getAttribute('h')) alt = parseFloat(alt) ; 
  if(dt=node.getAttribute('dt')) dt = parseFloat(dt) ; 
  if(time=node.getAttribute('t')) time = new Date(time) ; 
  pt = new pttype(pos,alt,time) ; 

  label = normalise(node.getAttribute('label')) ; // simplify white space
  caption = normalise(node.getAttribute('caption')) ;
  ty = normalise(node.getAttribute('type')) ;

  for(nodeno=0;nodeno<nodes.length;nodeno++) if(nodes[nodeno].nodeName=='img') 
    if(nodes[nodeno].getAttribute('type')=='pix')
      pt.photo.push(normalise(nodes[nodeno].textContent)) ;

  for(nodeno=0;nodeno<nodes.length;nodeno++) if(nodes[nodeno].nodeName=='mark') 
  { label = normalise(nodes[nodeno].getAttribute('label')) ; 
    caption = normalise(nodes[nodeno].getAttribute('caption')) ;
  }
  // it would be better if I simply stored label/caption in the point, and left
  // it for a subsequent process to set up a marker (as I do for photos)
  if(label) pt.setlabel(label,caption) ; 
  else if(caption) pt.setlabel('Generic',caption) ; 
  return { pt , dt , geo:(ty=='geo'?1:0)} ;
}
/* -------------------------------------------------------------------------- */

function getrteprops(nodes,xmlfile,type)
{ var props = new routetype(type) , 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')) ;
      props.ttype = type ; 
    }
    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 readrte(node,xmlfile)
{ var coords,i,route,t,r,prevt,props,type ;

  coords = node.childNodes ;
  type = normalise(node.getAttribute('type')) ; 
  route = getrteprops(coords,xmlfile,type) ; 

  if(route.level==0) 
  { for(prevt=i=0;i<coords.length;i++) if(coords[i].nodeType==1) 
      if(coords[i].tagName=='pt') if(r=getrtept(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() ; 
      if(r.geo||type=='geo') route.geo.push(r.pt) ; else route.pts.push(r.pt) ; 
    }
    route.pts = squash(route.pts) ; // squeeze out coincident points
  }
  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 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 readfit(rawdata)
{ var input = new Uint8Array(rawdata) , p = new routetype() ; 
  var ind,flag,tag,i,j,defn,lat,lon,pos,alt,time,item,nitem,prev,bigend ; 
  var gmsgnum ;
  if(input.length==0) return p ;
  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) ;
            if(alt) alt = alt/5 - 500 ; else alt = null ; 
          }
          else if(meaning==5) 
          { val = readfitvalue(input,ind,item) ;
            j = iconic.fitnames.indexOf(val) ; 
            label = iconic.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 = { lat:lat , lng: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();}) ;

  // now, if any points lack positions, fill them in
  for(i=0;i<pts.length;i++) if(!pts[i].pos)
  { time = pts[i].t.getTime() ; 
    for(ind=-1,j=i-1;j>=0&&!pts[j].pos;j--) ; 
    if(j>=0) ind = j ; 
    for(j=i+1;j<pts.length&&!pts[j].pos;j++) ; 
    if(j<pts.length) if( ind<0 || Math.abs(pts[ind].t.getTime()-time)>
                                  Math.abs(pts[j].t.getTime()-time) ) ind = j ; 
    if(ind>=0) pts[i].pos = { lat:pts[ind].pos.lat , lng:pts[ind].pos.lng } ; 
    else alert("no times and some missing positions") ; 
  }     
     
  // merge coincident points
  p.pts = squash(pts) ; 
  return p ; 
}
/* -------------------------------------------------------------------------- */

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 gentag(tag,val,sp) 
{ return sp + '<' + tag + '>' + xmlify(val) + '</' + tag + '>\n' ; }

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 gendesc(tag,desc,type,sp)
{ if( desc.indexOf('\n')>=0 || desc.indexOf('&')>=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' ; 
}
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 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 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 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 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 tcxpos(pfx,pos,prec,sp)
{ return sp + '<' + pfx + 'Position>\n' +
               gentag('LatitudeDegrees', pos.lat.toFixed(5+prec),sp+'  ') +
               gentag('LongitudeDegrees',pos.lng.toFixed(5+prec),sp+'  ') + 
         sp + '</' + pfx + 'Position>\n' ;
}
function gpxp(pos,prec) 
{ return 'lat="' + pos.lat.toFixed(5+prec) + 
       '" lon="' + pos.lng.toFixed(5+prec) + '"' ; 
}
/* -------------------------------------------------------------------------- */

function decimate(pts)
{ var i,j,k,d,r = new Array(pts.length) ; 
  for(r[0]=pts[0],k=1,i=0;i<pts.length-1;i=j,k++)
  { for(d=pts[i].delta,j=i+1;j<pts.length-1;d+=pts[j].delta,j++)
      if(pts[j].label||pts[j].photo.length||dist(pts[i].pos,pts[j].pos)>2) 
        break ; 
    r[k] = pts[j] ; // j is the first point significantly different than i 
    r[k-1].delta = d ; 
  } 
  r[k-1].delta = null ;
  r.length = k ; 
  return r ;  
}
/* -------------------------------------------------------------------------- */

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 readgps(response,filename)
{ var xmldoc , dotind = filename.lastIndexOf('.') , seg ; 
  var extn = filename.substring(dotind).toLowerCase() ; 

  if(extn!='.fit'&&extn!='.csv') 
  { if(!readgps.parser) readgps.parser = new DOMParser() ;
    xmldoc = readgps.parser.parseFromString(response,"application/xml") ;
  }

  // input formats
  if(extn=='.fit') 
  { seg = readfit(response) ; 
    seg.srcid = abbreviate(filename)[0] ; 
    if(!seg.title) seg.title = seg.srcid ; 
    return seg ; 
  }
  else if(extn=='.rte') return readrte(xmldoc.documentElement,filename) ; 
  else if(extn=='.gpx') return readgpx(xmldoc,filename) ; 
  else if(extn=='.kml') return readkml(xmldoc,filename) ; 
  else if(extn=='.tcx') 
  { seg = readtcx(xmldoc,filename) ;
    if(seg.pts.length>1) return seg ; else return seg.pts[0] ;
  }
  else if(extn=='.csv') 
  { seg = readcsv(xmldoc) ; 
    seg.title = abbreviate(filename)[0] ;
    return seg ; 
  }
  else return readgoogle(xmldoc) ; 
}
function writegps(props,pts,geo,fmt,precision) 
{ var ms ; 
  if(fmt=='.fit'||fmt=='.tcx') ms = gentimes(pts) ; 

  if(fmt=='.rte') return writerte(props,pts,geo,precision,0) ; 
  else if(fmt=='.gpx') return writegpx(props.title,props.desc,pts,precision) ; 
  else if(fmt=='.fit') return writefit(props.title,pts,ms.t) ; 
  else if(fmt=='.tcx') return writetcx(props.title,pts,ms.t,ms.d,precision) ; 
}
/* -------------------------------------------------------------------------- */

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

  // desc is a prop of a route alone or in index, or of a route of type segments
  if((props.level==0||props.type=='segments')&&nesting<2) 
    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) 
    { if(props.ttype)
        str += genurl('tracklink',props.tlink,'type',props.ttype,sp) ; 
      else str += genurl('tracklink',props.tlink,'mode',props.tmode,sp) ; 
    }
  }

  // the following properties only occur in bare routes 
  if(nesting==0&&(props.level==0||props.type=='segments')) 
  { 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) ; 
  }

  // optimisation occurs in bare routes and within routes of type 'segments'
  if((nesting==0&&props.level==0)||(nesting==1&&props.type=='segments')) 
    if(props.optim) str += genoptim('optimised',props.optim,sp) ; 

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

function writegpx(title,desc,xpts,precision) 
{ var i , ipts = squash(xpts) ;

  // header
  str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' +
        '<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' + rmdoc ; 

  // metadata
  if(title)
    str += '<metadata><name>' + title + '</name></metadata>\n' + 
           '<trk><name>' + title + '</name>\n<trkseg>\n' ;
  else str += '<trk><trkseg>\n' ;
  if(desc) str += gendesc('desc',desc,null,'  ') ; 

  // loop over points
  for(i=0;i<ipts.length;i++) 
  { str += '  <trkpt ' + gpxp(ipts[i].pos,precision) + '>\n' ; 
    if(ipts[i].h!=null&&ipts[i].h!=undefined)
      str += gentag('ele',ipts[i].h.toFixed(precision),'    ') ;
    if(ipts[i].t!=null&&ipts[i].t!=undefined)
      str += gentag('time',ipts[i].t.toISOString(),'    ') ;
    str += '  </trkpt>\n' ;
  }
  return str + '</trkseg>\n</trk>\n</gpx>\n' ;
}
/* -------------------------------------------------------------------------- */

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

  // header
  if(blx=='') 
  { str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' + rmdoc ; 
    gal = getgallery() ; 
  }

  // props
  if(ipts.length==0) rstr = '<route type="geo">\n' ; else rstr = '<route>\n' ;
  str += blx + rstr + writerteprops(props,nesting,gal) ;

  // route points
  for(tlast=-1,i=0;i<ipts.length+gpts.length;i++) 
  { rstr = '  <pt ' ; 
    if(i<ipts.length) pt = ipts[i] ; 
    else 
    { pt = gpts[i-ipts.length] ; if(ipts.length) rstr = '  <pt type="geo" ' ; }
    str += blx + rstr + gpxp(pt.pos,precision) ; 
    if(nesting==0||ptype=='segments')
    { if(pt.h||pt.h==0) str += ' h=\"' + pt.h.toFixed(precision) + '\"' ; 
      if(pt.t)
      { if(i<ipts.length&&tlast>=0&&i<tlast+10&&ipts[i-1].t)
        { dt = pt.t.getTime() - ipts[i-1].t.getTime() ;
          dt = (dt/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=\"' + pt.t.toISOString() + '\"' ; tlast = i ; }
      }
      else tlast = -1 ;
      if(pt.photo.length||pt.label||pt.caption)
      { str += '>\n' ;
        for(j=0;j<pt.photo.length;j++) 
          str += gentypedtag('img',pt.photo[j],'type','pix',blx+'    ') ;
        if(pt.label||pt.caption)
        { str += blx + '    <mark' ; 
          if(pt.label) str += ' label="' + pt.label + '"' ;
          if(pt.caption) str += ' caption="' + xmlify(pt.caption) + '"' ;
          str += '/>\n' ; 
        }
        str += blx + '  </pt>\n' ;
      }
      else str += '/>\n' ; 
    }
    else str += '/>\n' ; 
  }
  return str + blx + '</route>\n' ;
}
/* -------------------------------------------------------------------------- */

function writetcx(title,ipts,msecs,distance,precision) 
{ var clen = ipts.length , time , i , k , label , str , lab ; 

  if(title) title = title.substring(0,15) ; else title = L.untitledroute ;

  // header
  str = '<?xml version="1.0" encoding="UTF-8" standalone="no" ?>\n' 
      + '<TrainingCenterDatabase xmlns="http://www.garmin.com/xmlschemas/' +
        'TrainingCenterDatabase/v2"\n' +
        '          xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n' +
        '          xsi:schemaLocation="http://www.garmin.com/' +
        'xmlschemas/TrainingCenterDatabase/v2 ' + 
        'http://www.garmin.com/xmlschemas/TrainingCenterDatabasev2.xsd">\n' + 
        rmdoc ; 

  // metadata
  time = (msecs[clen-1]-msecs[0]) / 1000 ;
  str += '  <Folders><Courses><CourseFolder Name="Courses">\n' +
         '        <CourseNameRef><Id>'+title+'</Id></CourseNameRef>\n' +
         '  </CourseFolder></Courses></Folders>\n<Courses><Course>\n' +
         gentag('Name',title,'  ') + '  <Lap>\n' + 
         gentag('DistanceMeters',distance[clen-1].toFixed(0),'    ') +
         gentag('TotalTimeSeconds',time.toFixed(0),'    ') +
         tcxpos('Begin',ipts[0].pos,0,'    ') + 
         tcxpos('End'  ,ipts[clen-1].pos,0,'    ') + 
         gentag('Intensity','Active','    ') + '  </Lap>\n  <Track>\n' ; 

  // loop over trackpoints
  for(i=0;i<ipts.length;i++) 
  { str += '  <Trackpoint>\n' + tcxpos('',ipts[i].pos,precision,'    ') + 
                     gentag('DistanceMeters',distance[i].toFixed(0),'    ') ; 
    if(ipts[i].h!=null) 
      str += gentag('AltitudeMeters',ipts[i].h.toFixed(precision),'    ') ;
    str += gentag('Time',new Date(msecs[i]).toISOString(),'    ') ; 
    str += '    <SensorState>Absent</SensorState>\n  </Trackpoint>\n' ; 
  }

  str += '  </Track>\n\n' ;

  // finally loop over coursepoints
  for(i=0;i<ipts.length;i++) if(ipts[i].label)
  { label = ipts[i].label ;
    str += '  <CoursePoint>\n' + tcxpos('',ipts[i].pos,precision,'    ') ;
    k = iconic.names.indexOf(label) ; 
    if(k<0) lab = iconic.tcxnames[0] ; else lab = iconic.tcxnames[k] ; 
    str += gentag('PointType',lab,'    ') ;
    if(ipts[i].caption) str += gentag('Name',ipts[i].caption,'    ') ;
    if(ipts[i].h!=null) 
      str += gentag('AltitudeMeters',ipts[i].h.toFixed(precision),'    ') ;
    str += gentag('Time',new Date(msecs[i]).toISOString(),'    ') ;
    str += '  </CoursePoint>\n' ;
  }

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

function writefit(title,ipts,msecs) 
{ var clen=ipts.length,i,j,k,ncp,n,offs,dist,goth,nalt ;
  for(nalt=ncp=i=0;i<clen;i++) 
  { if(ipts[i].label||ipts[i].label==0) ncp += 1 ; 
    if(ipts[i].h==null||ipts[i].h==undefined) ; else nalt += 1 ; 
  }
  var utf8encoder = new TextEncoder() ; 
  if(!title) title = L.untitledroute ;
  var name = utf8encoder.encode(title) , tlen = name.length ; 
  var dlen = 17*clen + 2*nalt + 34*ncp ; 
  var fitlen = 199 + tlen + dlen ;
  var fit = new Uint8Array(fitlen) ; 
  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 += 1 ; 

  /* -------------------------------- 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 += 21 ; 

  /* ---------------------------- 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 += 22 ; 

  /* --------------- 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 += 21 ; 

  fitinject(fit,n,   0x45,0,0,32,0,6) ;   // definition header for coursepoint
  fitinject(fit,n+6,1,4,0x86) ;           // format of timestamp 
  fitinject(fit,n+9,2,4,0x85) ;           // format of lat 
  fitinject(fit,n+12,3,4,0x85) ;          // format of long 
  fitinject(fit,n+15,4,4,0x86) ;          // format of distance 
  fitinject(fit,n+18,5,1,0) ;             // format of coursepoint type 
  fitinject(fit,n+21,6,16,7) ;            // format of coursept name (16 bytes)
  n += 24 ; 

  fitinject(fit,n,   0x46,0,0,20,0,4) ;   // definition for record (no altitude)
  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,5,4,0x86) ;          // format of distance 
  n += 18 ; 

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

  for(dist=i=0;i<clen;dist+=ipts[i].delta,i++)
  { if(ipts[i].h==null||ipts[i].h==undefined) goth = 0 ; else goth = 1 ; 
    fitinject(fit,n,goth?4:6) ; // header for record: local msg type = 4/6
                                // for a record with/without an altitude
    fitinject4(fit,n+1,msecs[i]) ;
    fitinjectangle(fit,n+5,ipts[i].pos.lat) ; 
    fitinjectangle(fit,n+9,ipts[i].pos.lng) ; 
    n += 13 ; 
    if(goth) { fitinject2(fit,n,(ipts[i].h+500)*5) ; n += 2 ; }
    fitinject4(fit,n,dist*100) ;
    n += 4 ; 

    if(!ipts[i].label) continue ; 

    fitinject(fit,n,5) ;         // header for course point: local msg type = 5
    fitinject4(fit,n+1,msecs[i]) ;
    fitinjectangle(fit,n+5,ipts[i].pos.lat) ; 
    fitinjectangle(fit,n+9,ipts[i].pos.lng) ; 
    fitinject4(fit,n+13,dist*100) ;
    j = iconic.names.indexOf(ipts[i].label) ; 
    fitinject(fit,n+17,j<0?0:iconic.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+18+j,name[j]) ; 
    for(;j<16;j++) fitinject(fit,n+18+j,0) ; 
    n += 34 ; 
  }
  /* ----------------------------- 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 += 7 ; 

  /* ------------------------------ 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)) ; 
  n += 2 ; 
  if(n!=fitlen) alert("miscounted fit length") ; 
  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 ;
}
/* -------------------------------------------------------------------------- */
/*          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,nvalid,delta ; 

  // if I were to dispense with the call to flatten I could reduce the work
  var p = flatten(segments) ;
  n = p.length ; 
  for(nvalid=i=0;i<n&&nvalid<2;i++) if(p[i].h!=null) nvalid += 1 ; 
  if(nvalid<2) { this.h = [] ; return ; }
  this.d = D = p[n-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.ptno==0&&htarg>d0+eps/2&&htarg<d1-eps/2) 
    { this.h[hind] = undefined ; continue ; }
    if(p[i].sel.ptno==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]||this.h[i]==0)
  { 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] ;
  }

  // don't put hmin too low
  if(this.hmin>0) 
  { if(this.hmax>3*this.hmin) this.hmin = 0 ; 
    else this.hmin *= 1 - (this.hmax/this.hmin-1)/2 ; 
  }

  // enforce a span of at least 100m
  if(this.hmax<this.hmin+100)
  { delta = (100-(this.hmax-this.hmin))/2 ;
    if(this.hmin<0) this.hmin -= delta/10 ; 
    else if(this.hmin<delta-10) this.hmin = -10 ;
    else this.hmin -= delta ; 
    this.hmax = this.hmin + 100 ; 
  }

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

function profiletype(opt)
{ var id,node ; 
  this.curhandle = this.m = null ; 
  this.sel = { segno:0 , ptno:0 , type:null } ;
  this.ratio = window.devicePixelRatio ; 
  if(!this.ratio) this.ratio = 1 ; 
  if(opt<0)
  { this.ipixw = this.opixw = 300  ; 
    this.ipixh = this.opixh = 120  ; 
    this.active = -1 ; 
  }
  else
  { this.ipixw = 600 ; 
    this.ipixh = 180 ; 
    this.opixw = 620 ; 
    this.opixh = 200 ; 
    this.active = 0 ; 
  }
  this.innerw = Math.floor(this.ipixw*this.ratio) ; 
  this.innerh = Math.floor(this.ipixh*this.ratio) ; 
  this.outerw = Math.floor(this.opixw*this.ratio) ; 
  this.outerh = Math.floor(this.opixh*this.ratio) ; 
  
  this.offs   = Math.floor((this.outerw-this.innerw)/2) ;
  this.radius = Math.floor(20*this.ratio) ; 
  this.prodiv = domcreate('div',null,'id','prodiv') ;
  if(opt==1) 
  { 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) ; 
      if(node) node.parentNode.removeChild(node) ; 
    }
    this.m = null ;
  }
}
/* -------------------------------------------------------------------------- */

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

  if(pro.prodiv) for(div=pro.prodiv;div.childNodes.length>0;) 
    div.removeChild(div.childNodes[div.childNodes.length-1]) ;
  if(pro.active) { maxi = pro.innerw ; maxj = pro.innerh ; }
  else maxj = maxi = 2 * r ; 
  pro.m = new profilemaptype(segments,maxi) ; 
  if(!pro.m.h.length) return null ; 
  c = document.createElement('canvas') ;
  div.appendChild(c) ;

  if(pro.active<0) divstyle = 'display:inline-block;' ;
  else divstyle = 'position:absolute;right:0;top:0;' ; 
  if(pro.active)
  { divstyle += 'height:'+pro.opixh+'px;width:'+pro.opixw+'px' ; 
    c.setAttribute('width',pro.opixw) ; 
    c.setAttribute('height',pro.opixh) ; 
  }
  else
  { divstyle += 'height:42px;width:42px' ;
    c.setAttribute('width',42) ; 
    c.setAttribute('height',42) ; 
  }

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

  // pale grey background
  if(pro.active<0)
  { ctx.globalAlpha = 1 ; 
    ctx.font = Math.floor(0.5+8*pro.ratio)+"px Helvetica" ;
  }
  else 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 ; 
  hspan = pro.m.hmax - pro.m.hmin ;

  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)/hspan) ;
    }
    else if(h!==undefined)
    { h = maxj*(pro.m.hmax-h)/hspan ; hind = Math.floor(h) ; }

    segno = pro.m.pro2wp[i].segno ;
    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.hmax>pro.m.hmin+2500) step = 1000 ; 
    else if(pro.m.hmax>pro.m.hmin+1250) step = 500 ;
    else step = 100 ; 
    if(pro.active<0) step *= 2 ; 

    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)/hspan ; 
      y = 0.5 + Math.floor(y) ; // nearest half-integer
      ctx.beginPath() ; 
      ctx.lineWidth = 1 ; 
      ctx.strokeStyle = '#555' ; 
      ctx.moveTo(pro.offs,y) ;
      ctx.lineTo(pro.innerw+pro.offs,y) ; 
      ctx.stroke() ; 
      ctx.strokeStyle = 'black' ; 
      ctx.strokeText(i,pro.innerw-28*pro.ratio,y-2*pro.ratio) ;
    }

    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 ;
      x = 0.5 + Math.floor(x) ; // nearest half-integer
      ctx.beginPath() ; 
      ctx.lineWidth = 1 ; 
      ctx.strokeStyle = '#555' ; 
      ctx.moveTo(x,pro.offs+pro.innerh) ;
      ctx.lineTo(x,pro.offs+0.95*pro.innerh) ; 
      ctx.stroke() ; 
      ctx.strokeStyle = 'black' ; 
      if(i%inc==0) ctx.strokeText((i*step)/1000,x-8,pro.offs+0.93*pro.innerh) ;
    }
  }
  return divstyle ; 
}
/* -------------------------------------------------------------------------- */

function choosearrows(pts,nar,tgt)
{ var n=pts.length , total , i , narrow , d , j , ind , idash , ddash ;
  var nar , fom , seglen , arno , θ , ϕ , ii , dd , ibest , dbest , dpt ;
  var dj , score ; 

  for(total=i=0;i<n-1;i++) total += pts[i].delta ;
  if(nar) narrow = nar ; 
  else
  { if(tgt) tgt *= 1000 ; else tgt = 10000 ; 
    narrow = Math.floor(0.5+total/tgt) ;   // one per 10km
    if(!narrow&&total>0.4*tgt) narrow = 1 ;// but at least 1 if the route is 4km
  }
  if(narrow>n-1) narrow = n-1 ; 
  if(!narrow||n<2) return [] ; 
  seglen = total / narrow ; 
  ind = new Array(narrow) ; 

  // compute a score for an arrow at every halfway pt from i to i+1 
  score = new Array(n-1) ;
  for(i=0;i<n-1;i++) score[i] = null ; 

  for(dd=ii=d=i=0;i<n-1;d+=pts[i].delta,i++)
  { θ = angle(pts[i].pos,pts[i+1].pos) ; 
    pt = midpt(pts[i].pos,pts[i+1].pos) ; 
    dpt = d + pts[i].delta/2 ; 
    // compute a figure of merit as the largest divergence
    for(dj=dd,j=ii;j<n&&(dj<dpt+seglen/2||j==i+1);dj+=pts[j].delta,j++) 
      if(dj<dpt-seglen/2&&j<i) { ii = j+1 ; dd = dj+pts[j].delta ; } // skip
      else
    { ϕ = angle(pts[j].pos,pt) - θ ;
      if(j>i) ϕ += Math.PI ;
      while(ϕ>Math.PI) ϕ -= 2*Math.PI ; 
      while(ϕ<-Math.PI) ϕ += 2*Math.PI ; 
      // more divergence is accepted at a greater range, so divide it by distnce
      fom = Math.abs(ϕ) * seglen / Math.abs(dj-dpt) ; 
      if(score[i]==null||fom>score[i]) score[i] = fom ;
    }
  }

  // choose an arrow for the chunk from i to idash 
  // (we could do better by choosing narrow arrows by dynamic programming)
  for(nar=d=arno=i=0;i<n;arno++,i=idash,d=ddash)
  { tgt = ( total / narrow ) * (arno+1) ; 
    for(ddash=d,idash=i;idash<n;ddash+=pts[idash].delta,idash++) 
      if(ddash+pts[idash].delta/2>tgt) break ; 
    // i is the first pt of the sequence, i' the first after it
    if(idash==i) continue ;
    if(idash==i+1) 
    { ind[nar++] = { x:(d+pts[i].delta/2)/total , i } ; continue ; }
    for(qbest=null,dd=d,ii=i;ii<idash&&ii<n-1;dd+=pts[ii].delta,ii++) 
      if(score[ii]!=null) 
    { dpt = dd + pts[ii].delta/2 ; 
      fom = score[ii] / ( 0.001 + (dpt-d)*(ddash-dpt) ) ;
      if(qbest==null||fom<qbest) { qbest = fom ; ibest = ii ; dbest = dpt ; }
    }
    if(qbest!=null) ind[nar++] = { x:dbest/total , i:ibest } ;
  }
  ind.length = nar ; 
  return ind ;
}
/* ------------------------------------ geotag ------------------------------ */

function geotag(response,filename) 
{ var dataview = new DataView(response) , length = response.byteLength ;
  var bigend , tags , tag , exif , gps , start , i , n , ind , gpsptr ; 
  if(dataview.getUint8(0)!=0xFF||dataview.getUint8(1)!= 0xD8) 
  { alert(L.badjpg+filename) ; return null ; }
  for(offset=2;offset<length;offset+=2+dataview.getUint16(offset+2))
  { if(dataview.getUint8(offset)!=0xFF) 
    { alert(L.badjpg+filename) ; return null ; }
    if(dataview.getUint8(offset+1)==225) break ; 
  }

  if(getexifstring(dataview,offset+4,4)!="Exif") 
  { alert(L.badjpg+filename) ; return null ; }
  
  offset += 10 ; 

  // test for TIFF validity and endianness
  if(dataview.getUint16(offset)==0x4949) bigend = 0 ;
  else if(dataview.getUint16(offset)==0x4D4D) bigend = 1 ;
  else { alert(L.badjpg+filename) ; return null ; }

  if(dataview.getUint16(offset+2,1-bigend)!=0x002A) 
  { alert(L.badjpg+filename) ; return null ; }

  start = dataview.getUint32(offset+4,1-bigend) ;
  if(start<0x00000008) { alert(L.badjpg+filename) ; return null ; }

  n = dataview.getUint16(offset+start,!bigend) ; 
  for(gpsptr=null,i=0;i<n&&!gpsptr;i++) 
  { k = i*12 + offset + start + 2 ;
    if(dataview.getUint16(k,!bigend)==0x8825) 
      gpsptr = readexiftag(dataview,k,offset,offset+start,bigend) ;
  }
  if(gpsptr==null) { alert(L.notgeotagged+filename) ; return null ; }
  return getexifgps(dataview,offset,gpsptr,bigend) ;
}
function getexifgps(dataview,offset,start,bigend) 
{ var n = dataview.getUint16(offset+start,!bigend) , key , k , i ; 
  var vals=[null,null,null,null],negs=[0,'S',0,'W'],r={lat:null,lng:null} ;

  for(i=0;i<n;i++) 
  { k = i*12 + offset + start + 2 ;
    key = dataview.getUint16(k,!bigend) - 1 ;
    if(key>=0&&key<4) 
      vals[key] = readexiftag(dataview,k,offset,offset+start,bigend) ;
  }
  for(i=1;i<4;i+=2) 
  { if(vals[i]==null||vals[i].length!=3) return null ;
    k = vals[i][0] + (vals[i][1]+vals[i][2]/60)/60 ; 
    key = vals[i-1] ; 
    if( key && ( (typeof(key)=='number'&&key<0) 
              || (typeof(key)=='string'&&key.charAt(0)==negs[i]) ) ) k = -k ; 
    if(i==1) r.lat = k ; else r.lng = k ; 
  }    
  return r ; 
}
/* -------------------------------------------------------------------------- */

function readexiftag(data,k,tiffoffs,diroffs,bigend) 
{ var n = data.getUint32(k+4,!bigend) , offset,vals,i,num,den,
      valoffs = data.getUint32(k+8,!bigend)+tiffoffs ;

  switch(data.getUint16(k+2,!bigend)) 
  { case 1: // byte, 8-bit unsigned int
    case 7: // undefined, 8-bit byte, value depending on field
      offset = n>4 ? valoffs : (k+8) ;
      for(vals=new Array(n),i=0;i<n;i++) vals[i] = data.getUint8(offset+i) ;
      if(n==1) return vals[0] ; else return vals ; 

    case 2: // ascii, 8-bit byte
      offset = n>4 ? valoffs : (k+8) ;
      return getexifstring(data,offset,n-1) ;

    case 3: // short, 16 bit int
      offset = n>2 ? valoffs : (k+8) ;
      for(vals=new Array(n),i=0;i<n;i++) 
        vals[i] = data.getUint16(offset+2*i,!bigend) ;
      if(n==1) return vals[0] ; else return vals ; 

    case 4: // long, 32 bit int
      offset = (n>1) ? valoffs : (k+8) ;
      for(vals=new Array(n),i=0;i<n;i++) 
        vals[i] = data.getUint32(offset+4*i,!bigend) ;
      if(n==1) return vals[0] ; else return vals ; 

    case 5:    // rational = two long values, first is num, second is den
      for(vals=new Array(n),i=0;i<n;i++) 
      { num = data.getUint32(valoffs+8*i,!bigend) ;
        den = data.getUint32(valoffs+4+8*i,!bigend);
        vals[i] = new Number(num/den) ;
        vals[i].num = num ;
        vals[i].den = den ;
      }
      if(n==1) return vals[0] ; else return vals ; 

    case 9: // slong, 32 bit signed int
      offset = (n>1) ? valoffs : (k+8) ;
      for(vals=new Array(n),i=0;i<n;i++) 
        vals[i] = data.getInt32(offset+4*i,!bigend) ;
      if(n==1) return vals[0] ; else return vals ; 

    case 10: // signed rational, two slongs, first is num, second is den
      for(vals=new Array(n),i=0;i<n;i++) 
        vals[i] = data.getInt32(valoffs+8*i,!bigend) / 
                  data.getInt32(valoffs+4+8*i,!bigend) ;
      if(n==1) return vals[0] ; else return vals ; 
  }
}
function getexifstring(buffer,start,length) 
{ var outstr , n ;
  for(outstr='',n=start;n<start+length;n++) 
    outstr += String.fromCharCode(buffer.getUint8(n)) ;
  return outstr;
}
/* -------------------------------------------------------------------------- */

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

/* FileSaver.js
 * A saveAs() FileSaver implementation.
 * 1.1.20151003
 *
 * By Eli Grey, http://eligrey.com
 * License: MIT
 *   See https://github.com/eligrey/FileSaver.js/blob/master/LICENSE.md
 */

/*global self */
/*jslint bitwise: true, indent: 4, laxbreak: true, laxcomma: true, smarttabs: true, plusplus: true */

/*! @source http://purl.eligrey.com/github/FileSaver.js/blob/master/FileSaver.js */

var saveAs = saveAs || (function(view) {
    "use strict";
    // IE <10 is explicitly unsupported
    if (typeof navigator !== "undefined" && /MSIE [1-9]\./.test(navigator.userAgent)) {
        return;
    }
    var
          doc = view.document
          // only get URL when necessary in case Blob.js hasn't overridden it yet
        , get_URL = function() {
            return view.URL || view.webkitURL || view;
        }
        , save_link = doc.createElementNS("http://www.w3.org/1999/xhtml", "a")
        , can_use_save_link = "download" in save_link
        , click = function(node) {
            var event = new MouseEvent("click");
            node.dispatchEvent(event);
        }
        , is_safari = /Version\/[\d\.]+.*Safari/.test(navigator.userAgent)
        , webkit_req_fs = view.webkitRequestFileSystem
        , req_fs = view.requestFileSystem || webkit_req_fs || view.mozRequestFileSystem
        , throw_outside = function(ex) {
            (view.setImmediate || view.setTimeout)(function() {
                throw ex;
            }, 0);
        }
        , force_saveable_type = "application/octet-stream"
        , fs_min_size = 0
        // See https://code.google.com/p/chromium/issues/detail?id=375297#c7 and
        // https://github.com/eligrey/FileSaver.js/commit/485930a#commitcomment-8768047
        // for the reasoning behind the timeout and revocation flow
        , arbitrary_revoke_timeout = 500 // in ms
        , revoke = function(file) {
            var revoker = function() {
                if (typeof file === "string") { // file is an object URL
                    get_URL().revokeObjectURL(file);
                } else { // file is a File
                    file.remove();
                }
            };
            if (view.chrome) {
                revoker();
            } else {
                setTimeout(revoker, arbitrary_revoke_timeout);
            }
        }
        , dispatch = function(filesaver, event_types, event) {
            event_types = [].concat(event_types);
            var i = event_types.length;
            while (i--) {
                var listener = filesaver["on" + event_types[i]];
                if (typeof listener === "function") {
                    try {
                        listener.call(filesaver, event || filesaver);
                    } catch (ex) {
                        throw_outside(ex);
                    }
                }
            }
        }
        , auto_bom = function(blob) {
            // prepend BOM for UTF-8 XML and text/* types (including HTML)
            if (/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(blob.type)) {
                return new Blob(["\ufeff", blob], {type: blob.type});
            }
            return blob;
        }
        , FileSaver = function(blob, name, no_auto_bom) {
            if (!no_auto_bom) {
                blob = auto_bom(blob);
            }
            // First try a.download, then web filesystem, then object URLs
            var
                  filesaver = this
                , type = blob.type
                , blob_changed = false
                , object_url
                , target_view
                , dispatch_all = function() {
                    dispatch(filesaver, "writestart progress write writeend".split(" "));
                }
                // on any filesys errors revert to saving with object URLs
                , fs_error = function() {
                    if (target_view && is_safari && typeof FileReader !== "undefined") {
                        // Safari doesn't allow downloading of blob urls
                        var reader = new FileReader();
                        reader.onloadend = function() {
                            var base64Data = reader.result;
                            target_view.location.href = "data:attachment/file" + base64Data.slice(base64Data.search(/[,;]/));
                            filesaver.readyState = filesaver.DONE;
                            dispatch_all();
                        };
                        reader.readAsDataURL(blob);
                        filesaver.readyState = filesaver.INIT;
                        return;
                    }
                    // don't create more object URLs than needed
                    if (blob_changed || !object_url) {
                        object_url = get_URL().createObjectURL(blob);
                    }
                    if (target_view) {
                        target_view.location.href = object_url;
                    } else {
                        var new_tab = view.open(object_url, "_blank");
                        if (new_tab == undefined && is_safari) {
                            //Apple do not allow window.open, see http://bit.ly/1kZffRI
                            view.location.href = object_url
                        }
                    }
                    filesaver.readyState = filesaver.DONE;
                    dispatch_all();
                    revoke(object_url);
                }
                , abortable = function(func) {
                    return function() {
                        if (filesaver.readyState !== filesaver.DONE) {
                            return func.apply(this, arguments);
                        }
                    };
                }
                , create_if_not_found = {create: true, exclusive: false}
                , slice
            ;
            filesaver.readyState = filesaver.INIT;
            if (!name) {
                name = "download";
            }
            if (can_use_save_link) {
                object_url = get_URL().createObjectURL(blob);
                save_link.href = object_url;
                save_link.download = name;
                setTimeout(function() {
                    click(save_link);
                    dispatch_all();
                    revoke(object_url);
                    filesaver.readyState = filesaver.DONE;
                });
                return;
            }
            // Object and web filesystem URLs have a problem saving in Google Chrome when
            // viewed in a tab, so I force save with application/octet-stream
            // http://code.google.com/p/chromium/issues/detail?id=91158
            // Update: Google errantly closed 91158, I submitted it again:
            // https://code.google.com/p/chromium/issues/detail?id=389642
            if (view.chrome && type && type !== force_saveable_type) {
                slice = blob.slice || blob.webkitSlice;
                blob = slice.call(blob, 0, blob.size, force_saveable_type);
                blob_changed = true;
            }
            // Since I can't be sure that the guessed media type will trigger a download
            // in WebKit, I append .download to the filename.
            // https://bugs.webkit.org/show_bug.cgi?id=65440
            if (webkit_req_fs && name !== "download") {
                name += ".download";
            }
            if (type === force_saveable_type || webkit_req_fs) {
                target_view = view;
            }
            if (!req_fs) {
                fs_error();
                return;
            }
            fs_min_size += blob.size;
            req_fs(view.TEMPORARY, fs_min_size, abortable(function(fs) {
                fs.root.getDirectory("saved", create_if_not_found, abortable(function(dir) {
                    var save = function() {
                        dir.getFile(name, create_if_not_found, abortable(function(file) {
                            file.createWriter(abortable(function(writer) {
                                writer.onwriteend = function(event) {
                                    target_view.location.href = file.toURL();
                                    filesaver.readyState = filesaver.DONE;
                                    dispatch(filesaver, "writeend", event);
                                    revoke(file);
                                };
                                writer.onerror = function() {
                                    var error = writer.error;
                                    if (error.code !== error.ABORT_ERR) {
                                        fs_error();
                                    }
                                };
                                "writestart progress write abort".split(" ").forEach(function(event) {
                                    writer["on" + event] = filesaver["on" + event];
                                });
                                writer.write(blob);
                                filesaver.abort = function() {
                                    writer.abort();
                                    filesaver.readyState = filesaver.DONE;
                                };
                                filesaver.readyState = filesaver.WRITING;
                            }), fs_error);
                        }), fs_error);
                    };
                    dir.getFile(name, {create: false}, abortable(function(file) {
                        // delete file if it already exists
                        file.remove();
                        save();
                    }), abortable(function(ex) {
                        if (ex.code === ex.NOT_FOUND_ERR) {
                            save();
                        } else {
                            fs_error();
                        }
                    }));
                }), fs_error);
            }), fs_error);
        }
        , FS_proto = FileSaver.prototype
        , saveAs = function(blob, name, no_auto_bom) {
            return new FileSaver(blob, name, no_auto_bom);
        }
    ;
    // IE 10+ (native saveAs)
    if (typeof navigator !== "undefined" && navigator.msSaveOrOpenBlob) {
        return function(blob, name, no_auto_bom) {
            if (!no_auto_bom) {
                blob = auto_bom(blob);
            }
            return navigator.msSaveOrOpenBlob(blob, name || "download");
        };
    }

    FS_proto.abort = function() {
        var filesaver = this;
        filesaver.readyState = filesaver.DONE;
        dispatch(filesaver, "abort");
    };
    FS_proto.readyState = FS_proto.INIT = 0;
    FS_proto.WRITING = 1;
    FS_proto.DONE = 2;

    FS_proto.error =
    FS_proto.onwritestart =
    FS_proto.onprogress =
    FS_proto.onwrite =
    FS_proto.onabort =
    FS_proto.onerror =
    FS_proto.onwriteend =
        null;

    return saveAs;
}(
       typeof self !== "undefined" && self
    || typeof window !== "undefined" && window
    || this.content
));
// `self` is undefined in Firefox for Android content script context
// while `this` is nsIContentFrameMessageManager
// with an attribute `content` that corresponds to the window

if (typeof module !== "undefined" && module.exports) {
  module.exports.saveAs = saveAs;
} else if ((typeof define !== "undefined" && define !== null) && (define.amd != null)) {
  define([], function() {
    return saveAs;
  });
}
/* Blob.js
 * A Blob implementation.
 * 2014-07-24
 *
 * By Eli Grey, http://eligrey.com
 * By Devin Samarin, https://github.com/dsamarin
 * License: MIT
 *   See https://github.com/eligrey/Blob.js/blob/master/LICENSE.md
 */

/*global self, unescape */
/*jslint bitwise: true, regexp: true, confusion: true, es5: true, vars: true, white: true,
  plusplus: true */

/*! @source http://purl.eligrey.com/github/Blob.js/blob/master/Blob.js */

(function (view) {
    "use strict";

    view.URL = view.URL || view.webkitURL;

    if (view.Blob && view.URL) {
        try {
            new Blob;
            return;
        } catch (e) {}
    }

    // Internally we use a BlobBuilder implementation to base Blob off of
    // in order to support older browsers that only have BlobBuilder
    var BlobBuilder = view.BlobBuilder || view.WebKitBlobBuilder || view.MozBlobBuilder || (function(view) {
        var
              get_class = function(object) {
                return Object.prototype.toString.call(object).match(/^\[object\s(.*)\]$/)[1];
            }
            , FakeBlobBuilder = function BlobBuilder() {
                this.data = [];
            }
            , FakeBlob = function Blob(data, type, encoding) {
                this.data = data;
                this.size = data.length;
                this.type = type;
                this.encoding = encoding;
            }
            , FBB_proto = FakeBlobBuilder.prototype
            , FB_proto = FakeBlob.prototype
            , FileReaderSync = view.FileReaderSync
            , FileException = function(type) {
                this.code = this[this.name = type];
            }
            , file_ex_codes = (
                  "NOT_FOUND_ERR SECURITY_ERR ABORT_ERR NOT_READABLE_ERR ENCODING_ERR "
                + "NO_MODIFICATION_ALLOWED_ERR INVALID_STATE_ERR SYNTAX_ERR"
            ).split(" ")
            , file_ex_code = file_ex_codes.length
            , real_URL = view.URL || view.webkitURL || view
            , real_create_object_URL = real_URL.createObjectURL
            , real_revoke_object_URL = real_URL.revokeObjectURL
            , URL = real_URL
            , btoa = view.btoa
            , atob = view.atob

            , ArrayBuffer = view.ArrayBuffer
            , Uint8Array = view.Uint8Array

            , origin = /^[\w-]+:\/*\[?[\w\.:-]+\]?(?::[0-9]+)?/
        ;
        FakeBlob.fake = FB_proto.fake = true;
        while (file_ex_code--) {
            FileException.prototype[file_ex_codes[file_ex_code]] = file_ex_code + 1;
        }
        // Polyfill URL
        if (!real_URL.createObjectURL) {
            URL = view.URL = function(uri) {
                var
                      uri_info = document.createElementNS("http://www.w3.org/1999/xhtml", "a")
                    , uri_origin
                ;
                uri_info.href = uri;
                if (!("origin" in uri_info)) {
                    if (uri_info.protocol.toLowerCase() === "data:") {
                        uri_info.origin = null;
                    } else {
                        uri_origin = uri.match(origin);
                        uri_info.origin = uri_origin && uri_origin[1];
                    }
                }
                return uri_info;
            };
        }
        URL.createObjectURL = function(blob) {
            var
                  type = blob.type
                , data_URI_header
            ;
            if (type === null) {
                type = "application/octet-stream";
            }
            if (blob instanceof FakeBlob) {
                data_URI_header = "data:" + type;
                if (blob.encoding === "base64") {
                    return data_URI_header + ";base64," + blob.data;
                } else if (blob.encoding === "URI") {
                    return data_URI_header + "," + decodeURIComponent(blob.data);
                } if (btoa) {
                    return data_URI_header + ";base64," + btoa(blob.data);
                } else {
                    return data_URI_header + "," + encodeURIComponent(blob.data);
                }
            } else if (real_create_object_URL) {
                return real_create_object_URL.call(real_URL, blob);
            }
        };
        URL.revokeObjectURL = function(object_URL) {
            if (object_URL.substring(0, 5) !== "data:" && real_revoke_object_URL) {
                real_revoke_object_URL.call(real_URL, object_URL);
            }
        };
        FBB_proto.append = function(data/*, endings*/) {
            var bb = this.data;
            // decode data to a binary string
            if (Uint8Array && (data instanceof ArrayBuffer || data instanceof Uint8Array)) {
                var
                      str = ""
                    , buf = new Uint8Array(data)
                    , i = 0
                    , buf_len = buf.length
                ;
                for (; i < buf_len; i++) {
                    str += String.fromCharCode(buf[i]);
                }
                bb.push(str);
            } else if (get_class(data) === "Blob" || get_class(data) === "File") {
                if (FileReaderSync) {
                    var fr = new FileReaderSync;
                    bb.push(fr.readAsBinaryString(data));
                } else {
                    // async FileReader won't work as BlobBuilder is sync
                    throw new FileException("NOT_READABLE_ERR");
                }
            } else if (data instanceof FakeBlob) {
                if (data.encoding === "base64" && atob) {
                    bb.push(atob(data.data));
                } else if (data.encoding === "URI") {
                    bb.push(decodeURIComponent(data.data));
                } else if (data.encoding === "raw") {
                    bb.push(data.data);
                }
            } else {
                if (typeof data !== "string") {
                    data += ""; // convert unsupported types to strings
                }
                // decode UTF-16 to binary string
                bb.push(unescape(encodeURIComponent(data)));
            }
        };
        FBB_proto.getBlob = function(type) {
            if (!arguments.length) {
                type = null;
            }
            return new FakeBlob(this.data.join(""), type, "raw");
        };
        FBB_proto.toString = function() {
            return "[object BlobBuilder]";
        };
        FB_proto.slice = function(start, end, type) {
            var args = arguments.length;
            if (args < 3) {
                type = null;
            }
            return new FakeBlob(
                  this.data.slice(start, args > 1 ? end : this.data.length)
                , type
                , this.encoding
            );
        };
        FB_proto.toString = function() {
            return "[object Blob]";
        };
        FB_proto.close = function() {
            this.size = 0;
            delete this.data;
        };
        return FakeBlobBuilder;
    }(view));

    view.Blob = function(blobParts, options) {
        var type = options ? (options.type || "") : "";
        var builder = new BlobBuilder();
        if (blobParts) {
            for (var i = 0, len = blobParts.length; i < len; i++) {
                if (Uint8Array && blobParts[i] instanceof Uint8Array) {
                    builder.append(blobParts[i].buffer);
                }
                else {
                    builder.append(blobParts[i]);
                }
            }
        }
        var blob = builder.getBlob(type);
        if (!blob.slice && blob.webkitSlice) {
            blob.slice = blob.webkitSlice;
        }
        return blob;
    };

    var getPrototypeOf = Object.getPrototypeOf || function(object) {
        return object.__proto__;
    };
    view.Blob.prototype = getPrototypeOf(new view.Blob());
}(typeof self !== "undefined" && self || typeof window !== "undefined" && window || this.content || this));
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* Geodesy representation conversion functions                        (c) Chris Veness 2002-2020  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/latlong.html                                                    */
/* www.movable-type.co.uk/scripts/js/geodesy/geodesy-library.html#dms                             */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

/* eslint no-irregular-whitespace: [2, { skipComments: true }] */


/**
 * Latitude/longitude points may be represented as decimal degrees, or subdivided into sexagesimal
 * minutes and seconds. This module provides methods for parsing and representing degrees / minutes
 * / seconds.
 *
 * @module dms
 */


/* Degree-minutes-seconds (& cardinal directions) separator character */
let dmsSeparator = '\u202f'; // U+202F = 'narrow no-break space'


/**
 * Functions for parsing and representing degrees / minutes / seconds.
 */
class Dms {

    // note Unicode Degree = U+00B0. Prime = U+2032, Double prime = U+2033

    /**
     * Separator character to be used to separate degrees, minutes, seconds, and cardinal directions.
     *
     * Default separator is U+202F ‘narrow no-break space’.
     *
     * To change this (e.g. to empty string or full space), set Dms.separator prior to invoking
     * formatting.
     *
     * @example
     *   import LatLon, { Dms } from '/js/geodesy/latlon-spherical.js';
     *   const p = new LatLon(51.2, 0.33).toString('dms');  // 51° 12′ 00″ N, 000° 19′ 48″ E
     *   Dms.separator = '';                                // no separator
     *   const pʹ = new LatLon(51.2, 0.33).toString('dms'); // 51°12′00″N, 000°19′48″E
     */
    static get separator()     { return dmsSeparator; }
    static set separator(char) { dmsSeparator = char; }


    /**
     * Parses string representing degrees/minutes/seconds into numeric degrees.
     *
     * This is very flexible on formats, allowing signed decimal degrees, or deg-min-sec optionally
     * suffixed by compass direction (NSEW); a variety of separators are accepted. Examples -3.62,
     * '3 37 12W', '3°37′12″W'.
     *
     * Thousands/decimal separators must be comma/dot; use Dms.fromLocale to convert locale-specific
     * thousands/decimal separators.
     *
     * @param   {string|number} dms - Degrees or deg/min/sec in variety of formats.
     * @returns {number}        Degrees as decimal number.
     *
     * @example
     *   const lat = Dms.parse('51° 28′ 40.37″ N');
     *   const lon = Dms.parse('000° 00′ 05.29″ W');
     *   const p1 = new LatLon(lat, lon); // 51.4779°N, 000.0015°W
     */
    static parse(dms) {
        // check for signed decimal degrees without NSEW, if so return it directly
        if (!isNaN(parseFloat(dms)) && isFinite(dms)) return Number(dms);

        // strip off any sign or compass dir'n & split out separate d/m/s
        const dmsParts = String(dms).trim().replace(/^-/, '').replace(/[NSEW]$/i, '').split(/[^0-9.,]+/);
        if (dmsParts[dmsParts.length-1]=='') dmsParts.splice(dmsParts.length-1);  // from trailing symbol

        if (dmsParts == '') return NaN;

        // and convert to decimal degrees...
        let deg = null;
        switch (dmsParts.length) {
            case 3:  // interpret 3-part result as d/m/s
                deg = dmsParts[0]/1 + dmsParts[1]/60 + dmsParts[2]/3600;
                break;
            case 2:  // interpret 2-part result as d/m
                deg = dmsParts[0]/1 + dmsParts[1]/60;
                break;
            case 1:  // just d (possibly decimal) or non-separated dddmmss
                deg = dmsParts[0];
                // check for fixed-width unseparated format eg 0033709W
                //if (/[NS]/i.test(dmsParts)) deg = '0' + deg;  // - normalise N/S to 3-digit degrees
                //if (/[0-9]{7}/.test(deg)) deg = deg.slice(0,3)/1 + deg.slice(3,5)/60 + deg.slice(5)/3600;
                break;
            default:
                return NaN;
        }
        if (/^-|[WS]$/i.test(dms.trim())) deg = -deg; // take '-', west and south as -ve

        return Number(deg);
    }


    /**
     * Converts decimal degrees to deg/min/sec format
     *  - degree, prime, double-prime symbols are added, but sign is discarded, though no compass
     *    direction is added.
     *  - degrees are zero-padded to 3 digits; for degrees latitude, use .slice(1) to remove leading
     *    zero.
     *
     * @private
     * @param   {number} deg - Degrees to be formatted as specified.
     * @param   {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec.
     * @param   {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms.
     * @returns {string} Degrees formatted as deg/min/secs according to specified format.
     */
    static toDms(deg, format='d', dp=undefined) {
        if (isNaN(deg)) return null;  // give up here if we can't make a number from deg
        if (typeof deg == 'string' && deg.trim() == '') return null;
        if (typeof deg == 'boolean') return null;
        if (deg == Infinity) return null;
        if (deg == null) return null;

        // default values
        if (dp === undefined) {
            switch (format) {
                case 'd':   case 'deg':         dp = 4; break;
                case 'dm':  case 'deg+min':     dp = 2; break;
                case 'dms': case 'deg+min+sec': dp = 0; break;
                default:          format = 'd'; dp = 4; break; // be forgiving on invalid format
            }
        }

        deg = Math.abs(deg);  // (unsigned result ready for appending compass dir'n)

        let dms = null, d = null, m = null, s = null;
        switch (format) {
            default: // invalid format spec!
            case 'd': case 'deg':
                d = deg.toFixed(dp);                       // round/right-pad degrees
                if (d<100) d = '0' + d;                    // left-pad with leading zeros (note may include decimals)
                if (d<10) d = '0' + d;
                dms = d + '°';
                break;
            case 'dm': case 'deg+min':
                d = Math.floor(deg);                       // get component deg
                m = ((deg*60) % 60).toFixed(dp);           // get component min & round/right-pad
                if (m == 60) { m = (0).toFixed(dp); d++; } // check for rounding up
                d = ('000'+d).slice(-3);                   // left-pad with leading zeros
                if (m<10) m = '0' + m;                     // left-pad with leading zeros (note may include decimals)
                dms = d + '°'+Dms.separator + m + '′';
                break;
            case 'dms': case 'deg+min+sec':
                d = Math.floor(deg);                       // get component deg
                m = Math.floor((deg*3600)/60) % 60;        // get component min
                s = (deg*3600 % 60).toFixed(dp);           // get component sec & round/right-pad
                if (s == 60) { s = (0).toFixed(dp); m++; } // check for rounding up
                if (m == 60) { m = 0; d++; }               // check for rounding up
                d = ('000'+d).slice(-3);                   // left-pad with leading zeros
                m = ('00'+m).slice(-2);                    // left-pad with leading zeros
                if (s<10) s = '0' + s;                     // left-pad with leading zeros (note may include decimals)
                dms = d + '°'+Dms.separator + m + '′'+Dms.separator + s + '″';
                break;
        }

        return dms;
    }


    /**
     * Converts numeric degrees to deg/min/sec latitude (2-digit degrees, suffixed with N/S).
     *
     * @param   {number} deg - Degrees to be formatted as specified.
     * @param   {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec.
     * @param   {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms.
     * @returns {string} Degrees formatted as deg/min/secs according to specified format.
     *
     * @example
     *   const lat = Dms.toLat(-3.62, 'dms'); // 3°37′12″S
     */
    static toLat(deg, format, dp) {
        const lat = Dms.toDms(Dms.wrap90(deg), format, dp);
        return lat===null ? '–' : lat.slice(1) + Dms.separator + (deg<0 ? 'S' : 'N');  // knock off initial '0' for lat!
    }


    /**
     * Convert numeric degrees to deg/min/sec longitude (3-digit degrees, suffixed with E/W).
     *
     * @param   {number} deg - Degrees to be formatted as specified.
     * @param   {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec.
     * @param   {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms.
     * @returns {string} Degrees formatted as deg/min/secs according to specified format.
     *
     * @example
     *   const lon = Dms.toLon(-3.62, 'dms'); // 3°37′12″W
     */
    static toLon(deg, format, dp) {
        const lon = Dms.toDms(Dms.wrap180(deg), format, dp);
        return lon===null ? '–' : lon + Dms.separator + (deg<0 ? 'W' : 'E');
    }


    /**
     * Converts numeric degrees to deg/min/sec as a bearing (0°..360°).
     *
     * @param   {number} deg - Degrees to be formatted as specified.
     * @param   {string} [format=d] - Return value as 'd', 'dm', 'dms' for deg, deg+min, deg+min+sec.
     * @param   {number} [dp=4|2|0] - Number of decimal places to use – default 4 for d, 2 for dm, 0 for dms.
     * @returns {string} Degrees formatted as deg/min/secs according to specified format.
     *
     * @example
     *   const lon = Dms.toBrng(-3.62, 'dms'); // 356°22′48″
     */
    static toBrng(deg, format, dp) {
        const brng =  Dms.toDms(Dms.wrap360(deg), format, dp);
        return brng===null ? '–' : brng.replace('360', '0');  // just in case rounding took us up to 360°!
    }


    /**
     * Converts DMS string from locale thousands/decimal separators to JavaScript comma/dot separators
     * for subsequent parsing.
     *
     * Both thousands and decimal separators must be followed by a numeric character, to facilitate
     * parsing of single lat/long string (in which whitespace must be left after the comma separator).
     *
     * @param   {string} str - Degrees/minutes/seconds formatted with locale separators.
     * @returns {string} Degrees/minutes/seconds formatted with standard Javascript separators.
     *
     * @example
     *   const lat = Dms.fromLocale('51°28′40,12″N');                          // '51°28′40.12″N' in France
     *   const p = new LatLon(Dms.fromLocale('51°28′40,37″N, 000°00′05,29″W'); // '51.4779°N, 000.0015°W' in France
     */
    static fromLocale(str) {
        const locale = (123456.789).toLocaleString();
        const separator = { thousands: locale.slice(3, 4), decimal: locale.slice(7, 8) };
        return str.replace(separator.thousands, '⁜').replace(separator.decimal, '.').replace('⁜', ',');
    }


    /**
     * Converts DMS string from JavaScript comma/dot thousands/decimal separators to locale separators.
     *
     * Can also be used to format standard numbers such as distances.
     *
     * @param   {string} str - Degrees/minutes/seconds formatted with standard Javascript separators.
     * @returns {string} Degrees/minutes/seconds formatted with locale separators.
     *
     * @example
     *   const Dms.toLocale('123,456.789');                   // '123.456,789' in France
     *   const Dms.toLocale('51°28′40.12″N, 000°00′05.31″W'); // '51°28′40,12″N, 000°00′05,31″W' in France
     */
    static toLocale(str) {
        const locale = (123456.789).toLocaleString();
        const separator = { thousands: locale.slice(3, 4), decimal: locale.slice(7, 8) };
        return str.replace(/,([0-9])/, '⁜$1').replace('.', separator.decimal).replace('⁜', separator.thousands);
    }


    /**
     * Returns compass point (to given precision) for supplied bearing.
     *
     * @param   {number} bearing - Bearing in degrees from north.
     * @param   {number} [precision=3] - Precision (1:cardinal / 2:intercardinal / 3:secondary-intercardinal).
     * @returns {string} Compass point for supplied bearing.
     *
     * @example
     *   const point = Dms.compassPoint(24);    // point = 'NNE'
     *   const point = Dms.compassPoint(24, 1); // point = 'N'
     */
    static compassPoint(bearing, precision=3) {
        if (![ 1, 2, 3 ].includes(Number(precision))) throw new RangeError(`invalid precision ‘${precision}’`);
        // note precision could be extended to 4 for quarter-winds (eg NbNW), but I think they are little used

        bearing = Dms.wrap360(bearing); // normalise to range 0..360°

        const cardinals = [
            'N', 'NNE', 'NE', 'ENE',
            'E', 'ESE', 'SE', 'SSE',
            'S', 'SSW', 'SW', 'WSW',
            'W', 'WNW', 'NW', 'NNW' ];
        const n = 4 * 2**(precision-1); // no of compass points at req’d precision (1=>4, 2=>8, 3=>16)
        const cardinal = cardinals[Math.round(bearing*n/360)%n * 16/n];

        return cardinal;
    }


    /**
     * Constrain degrees to range -90..+90 (for latitude); e.g. -91 => -89, 91 => 89.
     *
     * @private
     * @param {number} degrees
     * @returns degrees within range -90..+90.
     */
    static wrap90(degrees) {
        if (-90<=degrees && degrees<=90) return degrees; // avoid rounding due to arithmetic ops if within range

        // latitude wrapping requires a triangle wave function; a general triangle wave is
        //     f(x) = 4a/p ⋅ | (x-p/4)%p - p/2 | - a
        // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator
        // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n'
        const x = degrees, a = 90, p = 360;
        return 4*a/p * Math.abs((((x-p/4)%p)+p)%p - p/2) - a;
    }

    /**
     * Constrain degrees to range -180..+180 (for longitude); e.g. -181 => 179, 181 => -179.
     *
     * @private
     * @param {number} degrees
     * @returns degrees within range -180..+180.
     */
    static wrap180(degrees) {
        if (-180<=degrees && degrees<=180) return degrees; // avoid rounding due to arithmetic ops if within range

        // longitude wrapping requires a sawtooth wave function; a general sawtooth wave is
        //     f(x) = (2ax/p - p/2) % p - a
        // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator
        // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n'
        const x = degrees, a = 180, p = 360;
        return (((2*a*x/p - p/2)%p)+p)%p - a;
    }

    /**
     * Constrain degrees to range 0..360 (for bearings); e.g. -1 => 359, 361 => 1.
     *
     * @private
     * @param {number} degrees
     * @returns degrees within range 0..360.
     */
    static wrap360(degrees) {
        if (0<=degrees && degrees<360) return degrees; // avoid rounding due to arithmetic ops if within range

        // bearing wrapping requires a sawtooth wave function with a vertical offset equal to the
        // amplitude and a corresponding phase shift; this changes the general sawtooth wave function from
        //     f(x) = (2ax/p - p/2) % p - a
        // to
        //     f(x) = (2ax/p) % p
        // where a = amplitude, p = period, % = modulo; however, JavaScript '%' is a remainder operator
        // not a modulo operator - for modulo, replace 'x%n' with '((x%n)+n)%n'
        const x = degrees, a = 180, p = 360;
        return (((2*a*x/p)%p)+p)%p;
    }

}


// Extend Number object with methods to convert between degrees & radians
Number.prototype.toRadians = function() { return this * Math.PI / 180; };
Number.prototype.toDegrees = function() { return this * 180 / Math.PI; };

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* Vector handling functions                                          (c) Chris Veness 2011-2016  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/geodesy/docs/module-vector3d.html                               */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

'use strict';


/**
 * Library of 3-d vector manipulation routines.
 *
 * In a geodesy context, these vectors may be used to represent:
 *  - n-vector representing a normal to point on Earth's surface
 *  - earth-centered, earth fixed vector (≡ Gade’s ‘p-vector’)
 *  - great circle normal to vector (on spherical earth model)
 *  - motion vector on Earth's surface
 *  - etc
 *
 * Functions return vectors as return results, so that operations can be chained.
 * @example var v = v1.cross(v2).dot(v3) // ≡ v1×v2⋅v3
 *
 * @module vector3d
 */


/**
 * Creates a 3-d vector.
 *
 * The vector may be normalised, or use x/y/z values for eg height relative to the sphere or
 * ellipsoid, distance from earth centre, etc.
 *
 * @constructor
 * @param {number} x - X component of vector.
 * @param {number} y - Y component of vector.
 * @param {number} z - Z component of vector.
 */
function Vector3d(x, y, z) {
    // allow instantiation without 'new'
    if (!(this instanceof Vector3d)) return new Vector3d(x, y, z);

    this.x = Number(x);
    this.y = Number(y);
    this.z = Number(z);
}


/**
 * Adds supplied vector to ‘this’ vector.
 *
 * @param   {Vector3d} v - Vector to be added to this vector.
 * @returns {Vector3d} Vector representing sum of this and v.
 */
Vector3d.prototype.plus = function(v) {
    if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object');

    return new Vector3d(this.x + v.x, this.y + v.y, this.z + v.z);
};


/**
 * Subtracts supplied vector from ‘this’ vector.
 *
 * @param   {Vector3d} v - Vector to be subtracted from this vector.
 * @returns {Vector3d} Vector representing difference between this and v.
 */
Vector3d.prototype.minus = function(v) {
    if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object');

    return new Vector3d(this.x - v.x, this.y - v.y, this.z - v.z);
};


/**
 * Multiplies ‘this’ vector by a scalar value.
 *
 * @param   {number}   x - Factor to multiply this vector by.
 * @returns {Vector3d} Vector scaled by x.
 */
Vector3d.prototype.times = function(x) {
    x = Number(x);

    return new Vector3d(this.x * x, this.y * x, this.z * x);
};


/**
 * Divides ‘this’ vector by a scalar value.
 *
 * @param   {number}   x - Factor to divide this vector by.
 * @returns {Vector3d} Vector divided by x.
 */
Vector3d.prototype.dividedBy = function(x) {
    x = Number(x);

    return new Vector3d(this.x / x, this.y / x, this.z / x);
};


/**
 * Multiplies ‘this’ vector by the supplied vector using dot (scalar) product.
 *
 * @param   {Vector3d} v - Vector to be dotted with this vector.
 * @returns {number} Dot product of ‘this’ and v.
 */
Vector3d.prototype.dot = function(v) {
    if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object');

    return this.x*v.x + this.y*v.y + this.z*v.z;
};


/**
 * Multiplies ‘this’ vector by the supplied vector using cross (vector) product.
 *
 * @param   {Vector3d} v - Vector to be crossed with this vector.
 * @returns {Vector3d} Cross product of ‘this’ and v.
 */
Vector3d.prototype.cross = function(v) {
    if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object');

    var x = this.y*v.z - this.z*v.y;
    var y = this.z*v.x - this.x*v.z;
    var z = this.x*v.y - this.y*v.x;

    return new Vector3d(x, y, z);
};


/**
 * Negates a vector to point in the opposite direction
 *
 * @returns {Vector3d} Negated vector.
 */
Vector3d.prototype.negate = function() {
    return new Vector3d(-this.x, -this.y, -this.z);
};


/**
 * Length (magnitude or norm) of ‘this’ vector
 *
 * @returns {number} Magnitude of this vector.
 */
Vector3d.prototype.length = function() {
    return Math.sqrt(this.x*this.x + this.y*this.y + this.z*this.z);
};


/**
 * Normalizes a vector to its unit vector
 * – if the vector is already unit or is zero magnitude, this is a no-op.
 *
 * @returns {Vector3d} Normalised version of this vector.
 */
Vector3d.prototype.unit = function() {
    var norm = this.length();
    if (norm == 1) return this;
    if (norm == 0) return this;

    var x = this.x/norm;
    var y = this.y/norm;
    var z = this.z/norm;

    return new Vector3d(x, y, z);
};


/**
 * Calculates the angle between ‘this’ vector and supplied vector.
 *
 * @param   {Vector3d} v
 * @param   {Vector3d} [n] - Plane normal: if supplied, angle is -π..+π, signed +ve if this->v is
 *     clockwise looking along n, -ve in opposite direction (if not supplied, angle is always 0..π).
 * @returns {number} Angle (in radians) between this vector and supplied vector.
 */
Vector3d.prototype.angleTo = function(v, n) {
    if (!(v instanceof Vector3d)) throw new TypeError('v is not Vector3d object');
    if (!(n instanceof Vector3d || n == undefined)) throw new TypeError('n is not Vector3d object');

    var sign = n==undefined ? 1 : Math.sign(this.cross(v).dot(n));
    var sinθ = this.cross(v).length() * sign;
    var cosθ = this.dot(v);

    return Math.atan2(sinθ, cosθ);
};


/**
 * Rotates ‘this’ point around an axis by a specified angle.
 *
 * @param   {Vector3d} axis - The axis being rotated around.
 * @param   {number}   theta - The angle of rotation (in radians).
 * @returns {Vector3d} The rotated point.
 */
Vector3d.prototype.rotateAround = function(axis, theta) {
    if (!(axis instanceof Vector3d)) throw new TypeError('axis is not Vector3d object');

    // en.wikipedia.org/wiki/Rotation_matrix#Rotation_matrix_from_axis_and_angle
    // en.wikipedia.org/wiki/Quaternions_and_spatial_rotation#Quaternion-derived_rotation_matrix
    var p1 = this.unit();
    var p = [ p1.x, p1.y, p1.z ]; // the point being rotated
    var a = axis.unit();          // the axis being rotated around
    var s = Math.sin(theta);
    var c = Math.cos(theta);
    // quaternion-derived rotation matrix
    var q = [
        [ a.x*a.x*(1-c) + c,     a.x*a.y*(1-c) - a.z*s, a.x*a.z*(1-c) + a.y*s ],
        [ a.y*a.x*(1-c) + a.z*s, a.y*a.y*(1-c) + c,     a.y*a.z*(1-c) - a.x*s ],
        [ a.z*a.x*(1-c) - a.y*s, a.z*a.y*(1-c) + a.x*s, a.z*a.z*(1-c) + c     ],
    ];
    // multiply q × p
    var qp = [ 0, 0, 0 ];
    for (var i=0; i<3; i++) {
        for (var j=0; j<3; j++) {
            qp[i] += q[i][j] * p[j];
        }
    }
    var p2 = new Vector3d(qp[0], qp[1], qp[2]);
    return p2;
    // qv en.wikipedia.org/wiki/Rodrigues'_rotation_formula...
};


/**
 * String representation of vector.
 *
 * @param   {number} [precision=3] - Number of decimal places to be used.
 * @returns {string} Vector represented as [x,y,z].
 */
Vector3d.prototype.toString = function(precision) {
    var p = (precision === undefined) ? 3 : Number(precision);

    var str = '[' + this.x.toFixed(p) + ',' + this.y.toFixed(p) + ',' + this.z.toFixed(p) + ']';

    return str;
};


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

/** Polyfill Math.sign for old browsers / IE */
if (Math.sign === undefined) {
    Math.sign = function(x) {
        x = +x; // convert to a number
        if (x === 0 || isNaN(x)) return x;
        return x > 0 ? 1 : -1;
    };
}

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
if (typeof module != 'undefined' && module.exports) module.exports = Vector3d; // ≡ export default Vector3d
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* Geodesy tools for an ellipsoidal earth model                       (c) Chris Veness 2005-2016  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/latlong-convert-coords.html                                     */
/* www.movable-type.co.uk/scripts/geodesy/docs/module-latlon-ellipsoidal.html                     */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

'use strict';
if (typeof module!='undefined' && module.exports) var Vector3d = require('./vector3d.js'); // ≡ import Vector3d from 'vector3d.js'


/**
 * Library of geodesy functions for operations on an ellipsoidal earth model.
 *
 * Includes ellipsoid parameters and datums for different coordinate systems, and methods for
 * converting between them and to cartesian coordinates.
 *
 * q.v. Ordnance Survey ‘A guide to coordinate systems in Great Britain’ Section 6
 * www.ordnancesurvey.co.uk/docs/support/guide-coordinate-systems-great-britain.pdf.
 *
 * @module   latlon-ellipsoidal
 * @requires dms
 */


/**
 * Creates lat/lon (polar) point with latitude & longitude values, on a specified datum.
 *
 * @constructor
 * @param {number}       lat - Geodetic latitude in degrees.
 * @param {number}       lon - Longitude in degrees.
 * @param {LatLon.datum} [datum=WGS84] - Datum this point is defined within.
 *
 * @example
 *     var p1 = new LatLon(51.4778, -0.0016, LatLon.datum.WGS84);
 */
function LatLon(lat, lon, datum) {
    // allow instantiation without 'new'
    if (!(this instanceof LatLon)) return new LatLon(lat, lon, datum);

    if (datum === undefined) datum = LatLon.datum.WGS84;

    this.lat = Number(lat);
    this.lon = Number(lon);
    this.datum = datum;
}


/**
 * Ellipsoid parameters; major axis (a), minor axis (b), and flattening (f) for each ellipsoid.
 */
LatLon.ellipsoid = {
    WGS84:         { a: 6378137,     b: 6356752.314245, f: 1/298.257223563 },
    Airy1830:      { a: 6377563.396, b: 6356256.909,    f: 1/299.3249646   },
    AiryModified:  { a: 6377340.189, b: 6356034.448,    f: 1/299.3249646   },
    Bessel1841:    { a: 6377397.155, b: 6356078.962818, f: 1/299.1528128   },
    Clarke1866:    { a: 6378206.4,   b: 6356583.8,      f: 1/294.978698214 },
    Clarke1880IGN: { a: 6378249.2,   b: 6356515.0,      f: 1/293.466021294 },
    GRS80:         { a: 6378137,     b: 6356752.314140, f: 1/298.257222101 },
    Intl1924:      { a: 6378388,     b: 6356911.946,    f: 1/297           }, // aka Hayford
    WGS72:         { a: 6378135,     b: 6356750.5,      f: 1/298.26        },
};

/**
 * Datums; with associated ellipsoid, and Helmert transform parameters to convert from WGS 84 into
 * given datum.
 *
 * Note that precision of various datums will vary, and WGS-84 (original) is not defined to be
 * accurate to better than ±1 metre. No transformation should be assumed to be accurate to better
 * than a meter; for many datums somewhat less.
 */
LatLon.datum = {
    // transforms: t in metres, s in ppm, r in arcseconds                    tx       ty        tz       s        rx       ry       rz
    ED50:       { ellipsoid: LatLon.ellipsoid.Intl1924,      transform: [   89.5,    93.8,    123.1,    -1.2,     0.0,     0.0,     0.156  ] },
    Irl1975:    { ellipsoid: LatLon.ellipsoid.AiryModified,  transform: [ -482.530, 130.596, -564.557,  -8.150,  -1.042,  -0.214,  -0.631  ] },
    NAD27:      { ellipsoid: LatLon.ellipsoid.Clarke1866,    transform: [    8,    -160,     -176,       0,       0,       0,       0      ] },
    NAD83:      { ellipsoid: LatLon.ellipsoid.GRS80,         transform: [    1.004,  -1.910,   -0.515,  -0.0015,  0.0267,  0.00034, 0.011  ] },
    NTF:        { ellipsoid: LatLon.ellipsoid.Clarke1880IGN, transform: [  168,      60,     -320,       0,       0,       0,       0      ] },
    OSGB36:     { ellipsoid: LatLon.ellipsoid.Airy1830,      transform: [ -446.448, 125.157, -542.060,  20.4894, -0.1502, -0.2470, -0.8421 ] },
    Potsdam:    { ellipsoid: LatLon.ellipsoid.Bessel1841,    transform: [ -582,    -105,     -414,      -8.3,     1.04,    0.35,   -3.08   ] },
    TokyoJapan: { ellipsoid: LatLon.ellipsoid.Bessel1841,    transform: [  148,    -507,     -685,       0,       0,       0,       0      ] },
    WGS72:      { ellipsoid: LatLon.ellipsoid.WGS72,         transform: [    0,       0,     -4.5,      -0.22,    0,       0,       0.554  ] },
    WGS84:      { ellipsoid: LatLon.ellipsoid.WGS84,         transform: [    0.0,     0.0,      0.0,     0.0,     0.0,     0.0,     0.0    ] },
};
/* sources:
 * - ED50:          www.gov.uk/guidance/oil-and-gas-petroleum-operations-notices#pon-4
 * - Irl1975:       www.osi.ie/wp-content/uploads/2015/05/transformations_booklet.pdf
 *   ... note: many sources have opposite sign to rotations - to be checked!
 * - NAD27:         en.wikipedia.org/wiki/Helmert_transformation
 * - NAD83: (2009); www.uvm.edu/giv/resources/WGS84_NAD83.pdf
 *   ... note: functionally ≡ WGS84 - if you *really* need to convert WGS84<->NAD83, you need more knowledge than this!
 * - NTF:           Nouvelle Triangulation Francaise geodesie.ign.fr/contenu/fichiers/Changement_systeme_geodesique.pdf
 * - OSGB36:        www.ordnancesurvey.co.uk/docs/support/guide-coordinate-systems-great-britain.pdf
 * - Potsdam:       kartoweb.itc.nl/geometrics/Coordinate%20transformations/coordtrans.html
 * - TokyoJapan:    www.geocachingtoolbox.com?page=datumEllipsoidDetails
 * - WGS72:         www.icao.int/safety/pbn/documentation/eurocontrol/eurocontrol wgs 84 implementation manual.pdf
 *
 * more transform parameters are available from earth-info.nga.mil/GandG/coordsys/datums/NATO_DT.pdf,
 * www.fieldenmaps.info/cconv/web/cconv_params.js
 */


/**
 * Converts ‘this’ lat/lon coordinate to new coordinate system.
 *
 * @param   {LatLon.datum} toDatum - Datum this coordinate is to be converted to.
 * @returns {LatLon} This point converted to new datum.
 *
 * @example
 *     var pWGS84 = new LatLon(51.4778, -0.0016, LatLon.datum.WGS84);
 *     var pOSGB = pWGS84.convertDatum(LatLon.datum.OSGB36); // 51.4773°N, 000.0000°E
 */
LatLon.prototype.convertDatum = function(toDatum) {
    var oldLatLon = this;
    var transform = null;

    if (oldLatLon.datum == LatLon.datum.WGS84) {
        // converting from WGS 84
        transform = toDatum.transform;
    }
    if (toDatum == LatLon.datum.WGS84) {
        // converting to WGS 84; use inverse transform (don't overwrite original!)
        transform = [];
        for (var p=0; p<7; p++) transform[p] = -oldLatLon.datum.transform[p];
    }
    if (transform == null) {
        // neither this.datum nor toDatum are WGS84: convert this to WGS84 first
        oldLatLon = this.convertDatum(LatLon.datum.WGS84);
        transform = toDatum.transform;
    }

    var oldCartesian = oldLatLon.toCartesian();                // convert polar to cartesian...
    var newCartesian = oldCartesian.applyTransform(transform); // ...apply transform...
    var newLatLon = newCartesian.toLatLonE(toDatum);           // ...and convert cartesian to polar

    return newLatLon;
};


/**
 * Converts ‘this’ point from (geodetic) latitude/longitude coordinates to (geocentric) cartesian
 * (x/y/z) coordinates.
 *
 * @returns {Vector3d} Vector pointing to lat/lon point, with x, y, z in metres from earth centre.
 */
LatLon.prototype.toCartesian = function() {
    var φ = this.lat.toRadians(), λ = this.lon.toRadians();
    var h = 0; // height above ellipsoid - not currently used
    var a = this.datum.ellipsoid.a, f = this.datum.ellipsoid.f;

    var sinφ = Math.sin(φ), cosφ = Math.cos(φ);
    var sinλ = Math.sin(λ), cosλ = Math.cos(λ);

    var eSq = 2*f - f*f;                      // 1st eccentricity squared ≡ (a²-b²)/a²
    var ν = a / Math.sqrt(1 - eSq*sinφ*sinφ); // radius of curvature in prime vertical

    var x = (ν+h) * cosφ * cosλ;
    var y = (ν+h) * cosφ * sinλ;
    var z = (ν*(1-eSq)+h) * sinφ;

    var point = new Vector3d(x, y, z);

    return point;
};


/**
 * Converts ‘this’ (geocentric) cartesian (x/y/z) point to (ellipsoidal geodetic) latitude/longitude
 * coordinates on specified datum.
 *
 * Uses Bowring’s (1985) formulation for μm precision in concise form.
 *
 * @param {LatLon.datum.transform} datum - Datum to use when converting point.
 */
Vector3d.prototype.toLatLonE = function(datum) {
    var x = this.x, y = this.y, z = this.z;
    var a = datum.ellipsoid.a, b = datum.ellipsoid.b, f = datum.ellipsoid.f;

    var e2 = 2*f - f*f;   // 1st eccentricity squared ≡ (a²-b²)/a²
    var ε2 = e2 / (1-e2); // 2nd eccentricity squared ≡ (a²-b²)/b²
    var p = Math.sqrt(x*x + y*y); // distance from minor axis
    var R = Math.sqrt(p*p + z*z); // polar radius

    // parametric latitude (Bowring eqn 17, replacing tanβ = z·a / p·b)
    var tanβ = (b*z)/(a*p) * (1+ε2*b/R);
    var sinβ = tanβ / Math.sqrt(1+tanβ*tanβ);
    var cosβ = sinβ / tanβ;

    // geodetic latitude (Bowring eqn 18: tanφ = z+ε²bsin³β / p−e²cos³β)
    var φ = isNaN(cosβ) ? 0 : Math.atan2(z + ε2*b*sinβ*sinβ*sinβ, p - e2*a*cosβ*cosβ*cosβ);

    // longitude
    var λ = Math.atan2(y, x);

    // height above ellipsoid (Bowring eqn 7) [not currently used]
    var sinφ = Math.sin(φ), cosφ = Math.cos(φ);
    var ν = a/Math.sqrt(1-e2*sinφ*sinφ); // length of the normal terminated by the minor axis
    var h = p*cosφ + z*sinφ - (a*a/ν);

    var point = new LatLon(φ.toDegrees(), λ.toDegrees(), datum);

    return point;
};

/**
 * Applies Helmert transform to ‘this’ point using transform parameters t.
 *
 * @private
 * @param   {number[]} t - Transform to apply to this point.
 * @returns {Vector3} Transformed point.
 */
Vector3d.prototype.applyTransform = function(t)   {
    // this point
    var x1 = this.x, y1 = this.y, z1 = this.z;

    // transform parameters
    var tx = t[0];                    // x-shift
    var ty = t[1];                    // y-shift
    var tz = t[2];                    // z-shift
    var s1 = t[3]/1e6 + 1;            // scale: normalise parts-per-million to (s+1)
    var rx = (t[4]/3600).toRadians(); // x-rotation: normalise arcseconds to radians
    var ry = (t[5]/3600).toRadians(); // y-rotation: normalise arcseconds to radians
    var rz = (t[6]/3600).toRadians(); // z-rotation: normalise arcseconds to radians

    // apply transform
    var x2 = tx + x1*s1 - y1*rz + z1*ry;
    var y2 = ty + x1*rz + y1*s1 - z1*rx;
    var z2 = tz - x1*ry + y1*rx + z1*s1;

    return new Vector3d(x2, y2, z2);
};


/**
 * Returns a string representation of ‘this’ point, formatted as degrees, degrees+minutes, or
 * degrees+minutes+seconds.
 *
 * @param   {string} [format=dms] - Format point as 'd', 'dm', 'dms'.
 * @param   {number} [dp=0|2|4] - Number of decimal places to use - default 0 for dms, 2 for dm, 4 for d.
 * @returns {string} Comma-separated latitude/longitude.
 */
LatLon.prototype.toString = function(format, dp) {
    return Dms.toLat(this.lat, format, dp) + ', ' + Dms.toLon(this.lon, format, dp);
};


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

/** Extend Number object with method to convert numeric degrees to radians */
if (Number.prototype.toRadians === undefined) {
    Number.prototype.toRadians = function() { return this * Math.PI / 180; };
}

/** Extend Number object with method to convert radians to numeric (signed) degrees */
if (Number.prototype.toDegrees === undefined) {
    Number.prototype.toDegrees = function() { return this * 180 / Math.PI; };
}

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* UTM / WGS-84 Conversion Functions                                  (c) Chris Veness 2014-2017  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/latlong-utm-mgrs.html                                           */
/* www.movable-type.co.uk/scripts/geodesy/docs/module-utm.html                                    */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

/**
 * Convert between Universal Transverse Mercator coordinates and WGS 84 latitude/longitude points.
 *
 * Method based on Karney 2011 ‘Transverse Mercator with an accuracy of a few nanometers’,
 * building on Krüger 1912 ‘Konforme Abbildung des Erdellipsoids in der Ebene’.
 *
 * @module   utm
 * @requires latlon-ellipsoidal
 */


/**
 * Creates a Utm coordinate object.
 *
 * @constructor
 * @param  {number} zone - UTM 6° longitudinal zone (1..60 covering 180°W..180°E).
 * @param  {string} hemisphere - N for northern hemisphere, S for southern hemisphere.
 * @param  {number} easting - Easting in metres from false easting (-500km from central meridian).
 * @param  {number} northing - Northing in metres from equator (N) or from false northing -10,000km (S).
 * @param  {LatLon.datum} [datum=WGS84] - Datum UTM coordinate is based on.
 * @param  {number} [convergence] - Meridian convergence (bearing of grid north clockwise from true
 *                  north), in degrees
 * @param  {number} [scale] - Grid scale factor
 * @throws {Error}  Invalid UTM coordinate
 *
 * @example
 *   var utmCoord = new Utm(31, 'N', 448251, 5411932);
 */
function Utm(zone, hemisphere, easting, northing, datum, convergence, scale) {
    if (!(this instanceof Utm)) { // allow instantiation without 'new'
        return new Utm(zone, hemisphere, easting, northing, datum, convergence, scale);
    }

    if (datum === undefined) datum = LatLon.datum.WGS84; // default if not supplied
    if (convergence === undefined) convergence = null;   // default if not supplied
    if (scale === undefined) scale = null;               // default if not supplied

    if (!(1<=zone && zone<=60)) throw new Error('Invalid UTM zone '+zone);
    if (!hemisphere.match(/[NS]/i)) throw new Error('Invalid UTM hemisphere '+hemisphere);
    // range-check easting/northing (with 40km overlap between zones) - is this worthwhile?
    //if (!(120e3<=easting && easting<=880e3)) throw new Error('Invalid UTM easting '+ easting);
    //if (!(0<=northing && northing<=10000e3)) throw new Error('Invalid UTM northing '+ northing);

    this.zone = Number(zone);
    this.hemisphere = hemisphere.toUpperCase();
    this.easting = Number(easting);
    this.northing = Number(northing);
    this.datum = datum;
    this.convergence = convergence===null ? null : Number(convergence);
    this.scale = scale===null ? null : Number(scale);
}


/**
 * Converts latitude/longitude to UTM coordinate.
 *
 * Implements Karney’s method, using Krüger series to order n^6, giving results accurate to 5nm for
 * distances up to 3900km from the central meridian.
 *
 * @returns {Utm}   UTM coordinate.
 * @throws  {Error} If point not valid, if point outside latitude range.
 *
 * @example
 *   var latlong = new LatLon(48.8582, 2.2945);
 *   var utmCoord = latlong.toUtm(); // utmCoord.toString(): '31 N 448252 5411933'
 */
LatLon.prototype.toUtm = function() {
    if (isNaN(this.lat) || isNaN(this.lon)) throw new Error('Invalid point');
    if (!(-80<=this.lat && this.lat<=84)) throw new Error('Outside UTM limits');

    var falseEasting = 500e3, falseNorthing = 10000e3;

    var zone = Math.floor((this.lon+180)/6) + 1; // longitudinal zone
    var λ0 = ((zone-1)*6 - 180 + 3).toRadians(); // longitude of central meridian

    // ---- handle Norway/Svalbard exceptions
    // grid zones are 8° tall; 0°N is offset 10 into latitude bands array
    var mgrsLatBands = 'CDEFGHJKLMNPQRSTUVWXX'; // X is repeated for 80-84°N
    var latBand = mgrsLatBands.charAt(Math.floor(this.lat/8+10));
    // adjust zone & central meridian for Norway
    if (zone==31 && latBand=='V' && this.lon>= 3) { zone++; λ0 += (6).toRadians(); }
    // adjust zone & central meridian for Svalbard
    if (zone==32 && latBand=='X' && this.lon<  9) { zone--; λ0 -= (6).toRadians(); }
    if (zone==32 && latBand=='X' && this.lon>= 9) { zone++; λ0 += (6).toRadians(); }
    if (zone==34 && latBand=='X' && this.lon< 21) { zone--; λ0 -= (6).toRadians(); }
    if (zone==34 && latBand=='X' && this.lon>=21) { zone++; λ0 += (6).toRadians(); }
    if (zone==36 && latBand=='X' && this.lon< 33) { zone--; λ0 -= (6).toRadians(); }
    if (zone==36 && latBand=='X' && this.lon>=33) { zone++; λ0 += (6).toRadians(); }

    var φ = this.lat.toRadians();      // latitude ± from equator
    var λ = this.lon.toRadians() - λ0; // longitude ± from central meridian

    var a = this.datum.ellipsoid.a, f = this.datum.ellipsoid.f;
    // WGS 84: a = 6378137, b = 6356752.314245, f = 1/298.257223563;

    var k0 = 0.9996; // UTM scale on the central meridian

    // ---- easting, northing: Karney 2011 Eq 7-14, 29, 35:

    var e = Math.sqrt(f*(2-f)); // eccentricity
    var n = f / (2 - f);        // 3rd flattening
    var n2 = n*n, n3 = n*n2, n4 = n*n3, n5 = n*n4, n6 = n*n5; // TODO: compare Horner-form accuracy?

    var cosλ = Math.cos(λ), sinλ = Math.sin(λ), tanλ = Math.tan(λ);

    var τ = Math.tan(φ); // τ ≡ tanφ, τʹ ≡ tanφʹ; prime (ʹ) indicates angles on the conformal sphere
    var σ = Math.sinh(e*Math.atanh(e*τ/Math.sqrt(1+τ*τ)));

    var τʹ = τ*Math.sqrt(1+σ*σ) - σ*Math.sqrt(1+τ*τ);

    var ξʹ = Math.atan2(τʹ, cosλ);
    var ηʹ = Math.asinh(sinλ / Math.sqrt(τʹ*τʹ + cosλ*cosλ));

    var A = a/(1+n) * (1 + 1/4*n2 + 1/64*n4 + 1/256*n6); // 2πA is the circumference of a meridian

    var α = [ null, // note α is one-based array (6th order Krüger expressions)
        1/2*n - 2/3*n2 + 5/16*n3 +   41/180*n4 -     127/288*n5 +      7891/37800*n6,
              13/48*n2 -  3/5*n3 + 557/1440*n4 +     281/630*n5 - 1983433/1935360*n6,
                       61/240*n3 -  103/140*n4 + 15061/26880*n5 +   167603/181440*n6,
                               49561/161280*n4 -     179/168*n5 + 6601661/7257600*n6,
                                                 34729/80640*n5 - 3418889/1995840*n6,
                                                              212378941/319334400*n6 ];

    var ξ = ξʹ;
    for (var j=1; j<=6; j++) ξ += α[j] * Math.sin(2*j*ξʹ) * Math.cosh(2*j*ηʹ);

    var η = ηʹ;
    for (var j=1; j<=6; j++) η += α[j] * Math.cos(2*j*ξʹ) * Math.sinh(2*j*ηʹ);

    var x = k0 * A * η;
    var y = k0 * A * ξ;

    // ---- convergence: Karney 2011 Eq 23, 24

    var pʹ = 1;
    for (var j=1; j<=6; j++) pʹ += 2*j*α[j] * Math.cos(2*j*ξʹ) * Math.cosh(2*j*ηʹ);
    var qʹ = 0;
    for (var j=1; j<=6; j++) qʹ += 2*j*α[j] * Math.sin(2*j*ξʹ) * Math.sinh(2*j*ηʹ);

    var γʹ = Math.atan(τʹ / Math.sqrt(1+τʹ*τʹ)*tanλ);
    var γʺ = Math.atan2(qʹ, pʹ);

    var γ = γʹ + γʺ;

    // ---- scale: Karney 2011 Eq 25

    var sinφ = Math.sin(φ);
    var kʹ = Math.sqrt(1 - e*e*sinφ*sinφ) * Math.sqrt(1 + τ*τ) / Math.sqrt(τʹ*τʹ + cosλ*cosλ);
    var kʺ = A / a * Math.sqrt(pʹ*pʹ + qʹ*qʹ);

    var k = k0 * kʹ * kʺ;

    // ------------

    // shift x/y to false origins
    x = x + falseEasting;             // make x relative to false easting
    if (y < 0) y = y + falseNorthing; // make y in southern hemisphere relative to false northing

    // round to reasonable precision
    x = Number(x.toFixed(6)); // nm precision
    y = Number(y.toFixed(6)); // nm precision
    var convergence = Number(γ.toDegrees().toFixed(9));
    var scale = Number(k.toFixed(12));

    var h = this.lat>=0 ? 'N' : 'S'; // hemisphere

    return new Utm(zone, h, x, y, this.datum, convergence, scale);
};

/**
 * Returns a string representation of a UTM coordinate.
 *
 * To distinguish from MGRS grid zone designators, a space is left between the zone and the
 * hemisphere.
 *
 * Note that UTM coordinates get rounded, not truncated (unlike MGRS grid references).
 *
 * @param   {number} [digits=0] - Number of digits to appear after the decimal point (3 ≡ mm).
 * @returns {string} A string representation of the coordinate.
 *
 * @example
 *   var utm = Utm.parse('31 N 448251 5411932').toString(4);  // 31 N 448251.0000 5411932.0000
 */
Utm.prototype.toString = function(digits) {
    digits = Number(digits||0); // default 0 if not supplied

    var z = this.zone<10 ? '0'+this.zone : this.zone; // leading zero
    var h = this.hemisphere;
    var e = this.easting;
    var n = this.northing;
    if (isNaN(z) || !h.match(/[NS]/) || isNaN(e) || isNaN(n)) return '';

    return z+' '+h+' '+e.toFixed(digits)+' '+n.toFixed(digits);
};


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

/** Polyfill Math.sinh for old browsers / IE */
if (Math.sinh === undefined) {
    Math.sinh = function(x) {
        return (Math.exp(x) - Math.exp(-x)) / 2;
    };
}

/** Polyfill Math.cosh for old browsers / IE */
if (Math.cosh === undefined) {
    Math.cosh = function(x) {
        return (Math.exp(x) + Math.exp(-x)) / 2;
    };
}

/** Polyfill Math.tanh for old browsers / IE */
if (Math.tanh === undefined) {
    Math.tanh = function(x) {
        return (Math.exp(x) - Math.exp(-x)) / (Math.exp(x) + Math.exp(-x));
    };
}

/** Polyfill Math.asinh for old browsers / IE */
if (Math.asinh === undefined) {
    Math.asinh = function(x) {
        return Math.log(x + Math.sqrt(1 + x*x));
    };
}

/** Polyfill Math.atanh for old browsers / IE */
if (Math.atanh === undefined) {
    Math.atanh = function(x) {
        return Math.log((1+x) / (1-x)) / 2;
    };
}

/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* Ordnance Survey Grid Reference functions                           (c) Chris Veness 2005-2017  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/latlong-gridref.html                                            */
/* www.movable-type.co.uk/scripts/geodesy/docs/module-osgridref.html                              */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

/**
 * Convert OS grid references to/from OSGB latitude/longitude points.
 *
 * Formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is inferior
 * to Krüger as used by e.g. Karney 2011.
 *
 * www.ordnancesurvey.co.uk/docs/support/guide-coordinate-systems-great-britain.pdf.
 *
 * @module   osgridref
 * @requires latlon-ellipsoidal
 */
/*
 * Converted 2015 to work with WGS84 by default, OSGB36 as option;
 * www.ordnancesurvey.co.uk/blog/2014/12/confirmation-on-changes-to-latitude-and-longitude
 */


/**
 * Creates an OsGridRef object.
 *
 * @constructor
 * @param {number} easting - Easting in metres from OS false origin.
 * @param {number} northing - Northing in metres from OS false origin.
 *
 * @example
 *   var grid = new OsGridRef(651409, 313177);
 */
function OsGridRef(easting, northing) {
    // allow instantiation without 'new'
    if (!(this instanceof OsGridRef)) return new OsGridRef(easting, northing);

    this.easting = Number(easting);
    this.northing = Number(northing);
}


/**
 * Converts latitude/longitude to Ordnance Survey grid reference easting/northing coordinate.
 *
 * Note formulation implemented here due to Thomas, Redfearn, etc is as published by OS, but is
 * inferior to Krüger as used by e.g. Karney 2011.
 *
 * @param   {LatLon}    point - latitude/longitude.
 * @returns {OsGridRef} OS Grid Reference easting/northing.
 *
 * @example
 *   var p = new LatLon(52.65798, 1.71605);
 *   var grid = OsGridRef.latLonToOsGrid(p); // grid.toString(): TG 51409 13177
 *   // for conversion of (historical) OSGB36 latitude/longitude point:
 *   var p = new LatLon(52.65757, 1.71791, LatLon.datum.OSGB36);
 */
OsGridRef.latLonToOsGrid = function(point) {
    if (!(point instanceof LatLon)) throw new TypeError('point is not LatLon object');

    // if necessary convert to OSGB36 first
    if (point.datum != LatLon.datum.OSGB36) point = point.convertDatum(LatLon.datum.OSGB36);

    var φ = point.lat.toRadians();
    var λ = point.lon.toRadians();

    var a = 6377563.396, b = 6356256.909;              // Airy 1830 major & minor semi-axes
    var F0 = 0.9996012717;                             // NatGrid scale factor on central meridian
    var φ0 = (49).toRadians(), λ0 = (-2).toRadians();  // NatGrid true origin is 49°N,2°W
    var N0 = -100000, E0 = 400000;                     // northing & easting of true origin, metres
    var e2 = 1 - (b*b)/(a*a);                          // eccentricity squared
    var n = (a-b)/(a+b), n2 = n*n, n3 = n*n*n;         // n, n², n³

    var cosφ = Math.cos(φ), sinφ = Math.sin(φ);
    var ν = a*F0/Math.sqrt(1-e2*sinφ*sinφ);            // nu = transverse radius of curvature
    var ρ = a*F0*(1-e2)/Math.pow(1-e2*sinφ*sinφ, 1.5); // rho = meridional radius of curvature
    var η2 = ν/ρ-1;                                    // eta = ?

    var Ma = (1 + n + (5/4)*n2 + (5/4)*n3) * (φ-φ0);
    var Mb = (3*n + 3*n*n + (21/8)*n3) * Math.sin(φ-φ0) * Math.cos(φ+φ0);
    var Mc = ((15/8)*n2 + (15/8)*n3) * Math.sin(2*(φ-φ0)) * Math.cos(2*(φ+φ0));
    var Md = (35/24)*n3 * Math.sin(3*(φ-φ0)) * Math.cos(3*(φ+φ0));
    var M = b * F0 * (Ma - Mb + Mc - Md);              // meridional arc

    var cos3φ = cosφ*cosφ*cosφ;
    var cos5φ = cos3φ*cosφ*cosφ;
    var tan2φ = Math.tan(φ)*Math.tan(φ);
    var tan4φ = tan2φ*tan2φ;

    var I = M + N0;
    var II = (ν/2)*sinφ*cosφ;
    var III = (ν/24)*sinφ*cos3φ*(5-tan2φ+9*η2);
    var IIIA = (ν/720)*sinφ*cos5φ*(61-58*tan2φ+tan4φ);
    var IV = ν*cosφ;
    var V = (ν/6)*cos3φ*(ν/ρ-tan2φ);
    var VI = (ν/120) * cos5φ * (5 - 18*tan2φ + tan4φ + 14*η2 - 58*tan2φ*η2);

    var Δλ = λ-λ0;
    var Δλ2 = Δλ*Δλ, Δλ3 = Δλ2*Δλ, Δλ4 = Δλ3*Δλ, Δλ5 = Δλ4*Δλ, Δλ6 = Δλ5*Δλ;

    var N = I + II*Δλ2 + III*Δλ4 + IIIA*Δλ6;
    var E = E0 + IV*Δλ + V*Δλ3 + VI*Δλ5;

    N = Number(N.toFixed(3)); // round to mm precision
    E = Number(E.toFixed(3));

    return new OsGridRef(E, N); // gets truncated to SW corner of 1m grid square
};




/**
 * Converts ‘this’ numeric grid reference to standard OS grid reference.
 *
 * @param   {number} [digits=10] - Precision of returned grid reference (10 digits = metres);
 *   digits=0 will return grid reference in numeric format.
 * @returns {string} This grid reference in standard format.
 *
 * @example
 *   var ref = new OsGridRef(651409, 313177).toString(); // TG 51409 13177
 */
OsGridRef.prototype.toString = function(digits) {
    digits = (digits === undefined) ? 10 : Number(digits);
    if (isNaN(digits) || digits%2!=0 || digits>16) 
      throw new RangeError('Invalid precision ‘'+digits+'’');

    var e = this.easting;
    var n = this.northing;
    if (isNaN(e) || isNaN(n)) throw new Error('Invalid grid reference');

    // use digits = 0 to return numeric format (in metres, allowing for decimals & for northing > 1e6)
    if (digits == 0) {
        var eInt = Math.floor(e), eDec = e - eInt;
        var nInt = Math.floor(n), nDec = n - nInt;
        var ePad = ('000000'+eInt).slice(-6) + (eDec>0 ? eDec.toFixed(3).slice(1) : '');
        var nPad = (nInt<1e6 ? ('000000'+nInt).slice(-6) : nInt) + (nDec>0 ? nDec.toFixed(3).slice(1) : '');
        return ePad + ',' + nPad;
    }

    // get the 100km-grid indices
    var e100k = Math.floor(e/100000), n100k = Math.floor(n/100000);

    if (e100k<0 || e100k>6 || n100k<0 || n100k>12) return '';

    // translate those into numeric equivalents of the grid letters
    var l1 = (19-n100k) - (19-n100k)%5 + Math.floor((e100k+10)/5);
    var l2 = (19-n100k)*5%25 + e100k%5;

    // compensate for skipped 'I' and build grid letter-pairs
    if (l1 > 7) l1++;
    if (l2 > 7) l2++;
    var letterPair = String.fromCharCode(l1+'A'.charCodeAt(0), l2+'A'.charCodeAt(0));

    // strip 100km-grid indices from easting & northing, and reduce precision
    e = Math.floor((e%100000)/Math.pow(10, 5-digits/2));
    n = Math.floor((n%100000)/Math.pow(10, 5-digits/2));

    // pad eastings & northings with leading zeros (just in case, allow up to 16-digit (mm) refs)
    e = ('00000000'+e).slice(-digits/2);
    n = ('00000000'+n).slice(-digits/2);

    return letterPair + ' ' + e + ' ' + n;
};
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */
/* Latitude/longitude spherical geodesy tools                         (c) Chris Veness 2002-2022  */
/*                                                                                   MIT Licence  */
/* www.movable-type.co.uk/scripts/latlong.html                                                    */
/* www.movable-type.co.uk/scripts/geodesy-library.html#latlon-spherical                           */
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

const π = Math.PI;


/**
 * Library of geodesy functions for operations on a spherical earth model.
 *
 * Includes distances, bearings, destinations, etc, for both great circle paths and rhumb lines,
 * and other related functions.
 *
 * All calculations are done using simple spherical trigonometric formulae.
 *
 * @module latlon-spherical
 */

// note greek letters (e.g. φ, λ, θ) are used for angles in radians to distinguish from angles in
// degrees (e.g. lat, lon, brng)


/* LatLonSpherical - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */


/**
 * Latitude/longitude points on a spherical model earth, and methods for calculating distances,
 * bearings, destinations, etc on (orthodromic) great-circle paths and (loxodromic) rhumb lines.
 */
class LatLonSpherical {

    /**
     * Creates a latitude/longitude point on the earth’s surface, using a spherical model earth.
     *
     * @param  {number} lat - Latitude (in degrees).
     * @param  {number} lon - Longitude (in degrees).
     * @throws {TypeError} Invalid lat/lon.
     *
     * @example
     *   import LatLon from '/js/geodesy/latlon-spherical.js';
     *   const p = new LatLon(52.205, 0.119);
     */
    constructor(lat, lon) {
        if (isNaN(lat)) throw new TypeError(`invalid lat ‘${lat}’`);
        if (isNaN(lon)) throw new TypeError(`invalid lon ‘${lon}’`);

        this._lat = Dms.wrap90(Number(lat));
        this._lon = Dms.wrap180(Number(lon));
    }


    /**
     * Latitude in degrees north from equator (including aliases lat, latitude): can be set as
     * numeric or hexagesimal (deg-min-sec); returned as numeric.
     */
    get lat()       { return this._lat; }
    get latitude()  { return this._lat; }
    set lat(lat) {
        this._lat = isNaN(lat) ? Dms.wrap90(Dms.parse(lat)) : Dms.wrap90(Number(lat));
        if (isNaN(this._lat)) throw new TypeError(`invalid lat ‘${lat}’`);
    }
    set latitude(lat) {
        this._lat = isNaN(lat) ? Dms.wrap90(Dms.parse(lat)) : Dms.wrap90(Number(lat));
        if (isNaN(this._lat)) throw new TypeError(`invalid latitude ‘${lat}’`);
    }

    /**
     * Longitude in degrees east from international reference meridian (including aliases lon, lng,
     * longitude): can be set as numeric or hexagesimal (deg-min-sec); returned as numeric.
     */
    get lon()       { return this._lon; }
    get lng()       { return this._lon; }
    get longitude() { return this._lon; }
    set lon(lon) {
        this._lon = isNaN(lon) ? Dms.wrap180(Dms.parse(lon)) : Dms.wrap180(Number(lon));
        if (isNaN(this._lon)) throw new TypeError(`invalid lon ‘${lon}’`);
    }
    set lng(lon) {
        this._lon = isNaN(lon) ? Dms.wrap180(Dms.parse(lon)) : Dms.wrap180(Number(lon));
        if (isNaN(this._lon)) throw new TypeError(`invalid lng ‘${lon}’`);
    }
    set longitude(lon) {
        this._lon = isNaN(lon) ? Dms.wrap180(Dms.parse(lon)) : Dms.wrap180(Number(lon));
        if (isNaN(this._lon)) throw new TypeError(`invalid longitude ‘${lon}’`);
    }

    /**
     * Parses a latitude/longitude point from a variety of formats.
     *
     * Latitude & longitude (in degrees) can be supplied as two separate parameters, as a single
     * comma-separated lat/lon string, or as a single object with { lat, lon } or GeoJSON properties.
     *
     * The latitude/longitude values may be numeric or strings; they may be signed decimal or
     * deg-min-sec (hexagesimal) suffixed by compass direction (NSEW); a variety of separators are
     * accepted. Examples -3.62, '3 37 12W', '3°37′12″W'.
     *
     * Thousands/decimal separators must be comma/dot; use Dms.fromLocale to convert locale-specific
     * thousands/decimal separators.
     *
     * @param   {number|string|Object} lat|latlon - Latitude (in degrees) or comma-separated lat/lon or lat/lon object.
     * @param   {number|string}        [lon]      - Longitude (in degrees).
     * @returns {LatLon} Latitude/longitude point.
     * @throws  {TypeError} Invalid point.
     *
     * @example
     *   const p1 = LatLon.parse(52.205, 0.119);                                    // numeric pair (≡ new LatLon)
     *   const p2 = LatLon.parse('52.205', '0.119');                                // numeric string pair (≡ new LatLon)
     *   const p3 = LatLon.parse('52.205, 0.119');                                  // single string numerics
     *   const p4 = LatLon.parse('52°12′18.0″N', '000°07′08.4″E');                  // DMS pair
     *   const p5 = LatLon.parse('52°12′18.0″N, 000°07′08.4″E');                    // single string DMS
     *   const p6 = LatLon.parse({ lat: 52.205, lon: 0.119 });                      // { lat, lon } object numeric
     *   const p7 = LatLon.parse({ lat: '52°12′18.0″N', lng: '000°07′08.4″E' });    // { lat, lng } object DMS
     *   const p8 = LatLon.parse({ type: 'Point', coordinates: [ 0.119, 52.205] }); // GeoJSON
     */
    static parse(...args) {
        if (args.length == 0) throw new TypeError('invalid (empty) point');
        if (args[0]===null || args[1]===null) throw new TypeError('invalid (null) point');

        let lat=undefined, lon=undefined;

        if (args.length == 2) { // regular (lat, lon) arguments
            [ lat, lon ] = args;
            lat = Dms.wrap90(Dms.parse(lat));
            lon = Dms.wrap180(Dms.parse(lon));
            if (isNaN(lat) || isNaN(lon)) throw new TypeError(`invalid point ‘${args.toString()}’`);
        }

        if (args.length == 1 && typeof args[0] == 'string') { // single comma-separated lat,lon string
            [ lat, lon ] = args[0].split(',');
            lat = Dms.wrap90(Dms.parse(lat));
            lon = Dms.wrap180(Dms.parse(lon));
            if (isNaN(lat) || isNaN(lon)) throw new TypeError(`invalid point ‘${args[0]}’`);
        }

        if (args.length == 1 && typeof args[0] == 'object') { // single { lat, lon } object
            const ll = args[0];
            if (ll.type == 'Point' && Array.isArray(ll.coordinates)) { // GeoJSON
                [ lon, lat ] = ll.coordinates;
            } else { // regular { lat, lon } object
                if (ll.latitude  != undefined) lat = ll.latitude;
                if (ll.lat       != undefined) lat = ll.lat;
                if (ll.longitude != undefined) lon = ll.longitude;
                if (ll.lng       != undefined) lon = ll.lng;
                if (ll.lon       != undefined) lon = ll.lon;
                lat = Dms.wrap90(Dms.parse(lat));
                lon = Dms.wrap180(Dms.parse(lon));
            }
            if (isNaN(lat) || isNaN(lon)) throw new TypeError(`invalid point ‘${JSON.stringify(args[0])}’`);
        }

        if (isNaN(lat) || isNaN(lon)) throw new TypeError(`invalid point ‘${args.toString()}’`);

        return new LatLonSpherical(lat, lon);
    }


    /**
     * Returns the distance along the surface of the earth from ‘this’ point to destination point.
     *
     * Uses haversine formula: a = sin²(Δφ/2) + cosφ1·cosφ2 · sin²(Δλ/2); d = 2 · atan2(√a, √(a-1)).
     *
     * @param   {LatLon} point - Latitude/longitude of destination point.
     * @param   {number} [radius=6371e3] - Radius of earth (defaults to mean radius in metres).
     * @returns {number} Distance between this point and destination point, in same units as radius.
     * @throws  {TypeError} Invalid radius.
     *
     * @example
     *   const p1 = new LatLon(52.205, 0.119);
     *   const p2 = new LatLon(48.857, 2.351);
     *   const d = p1.distanceTo(p2);       // 404.3×10³ m
     *   const m = p1.distanceTo(p2, 3959); // 251.2 miles
     */
    distanceTo(point, radius=6371e3) {
        if (!(point instanceof LatLonSpherical)) point = LatLonSpherical.parse(point); // allow literal forms
        if (isNaN(radius)) throw new TypeError(`invalid radius ‘${radius}’`);

        // a = sin²(Δφ/2) + cos(φ1)⋅cos(φ2)⋅sin²(Δλ/2)
        // δ = 2·atan2(√(a), √(1−a))
        // see mathforum.org/library/drmath/view/51879.html for derivation

        const R = radius;
        const φ1 = this.lat.toRadians(),  λ1 = this.lon.toRadians();
        const φ2 = point.lat.toRadians(), λ2 = point.lon.toRadians();
        const Δφ = φ2 - φ1;
        const Δλ = λ2 - λ1;

        const a = Math.sin(Δφ/2)*Math.sin(Δφ/2) + Math.cos(φ1)*Math.cos(φ2) * Math.sin(Δλ/2)*Math.sin(Δλ/2);
        const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
        const d = R * c;

        return d;
    }


    /**
     * Returns the initial bearing from ‘this’ point to destination point.
     *
     * @param   {LatLon} point - Latitude/longitude of destination point.
     * @returns {number} Initial bearing in degrees from north (0°..360°).
     *
     * @example
     *   const p1 = new LatLon(52.205, 0.119);
     *   const p2 = new LatLon(48.857, 2.351);
     *   const b1 = p1.initialBearingTo(p2); // 156.2°
     */
    initialBearingTo(point) {
        if (!(point instanceof LatLonSpherical)) point = LatLonSpherical.parse(point); // allow literal forms
        if (this.equals(point)) return NaN; // coincident points

        // tanθ = sinΔλ⋅cosφ2 / cosφ1⋅sinφ2 − sinφ1⋅cosφ2⋅cosΔλ
        // see mathforum.org/library/drmath/view/55417.html for derivation

        const φ1 = this.lat.toRadians();
        const φ2 = point.lat.toRadians();
        const Δλ = (point.lon - this.lon).toRadians();

        const x = Math.cos(φ1) * Math.sin(φ2) - Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
        const y = Math.sin(Δλ) * Math.cos(φ2);
        const θ = Math.atan2(y, x);

        const bearing = θ.toDegrees();

        return Dms.wrap360(bearing);
    }
    /* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */


    /**
     * Checks if another point is equal to ‘this’ point.
     *
     * @param   {LatLon} point - Point to be compared against this point.
     * @returns {bool}   True if points have identical latitude and longitude values.
     *
     * @example
     *   const p1 = new LatLon(52.205, 0.119);
     *   const p2 = new LatLon(52.205, 0.119);
     *   const equal = p1.equals(p2); // true
     */
    equals(point) {
        if (!(point instanceof LatLonSpherical)) point = LatLonSpherical.parse(point); // allow literal forms

        if (Math.abs(this.lat - point.lat) > Number.EPSILON) return false;
        if (Math.abs(this.lon - point.lon) > Number.EPSILON) return false;

        return true;
    }

}
/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -  */

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

The main page (index.php) adds a further 353 lines, and the file server another 292. pixlib.js adds almost 2000 more.