UNPKG

igc-xc-score

Version:

igc-xc-score is a paragliding and hang-gliding XC scoring program in vanilla JS

303 lines (283 loc) 10.1 kB
/* global __DEBUG__:false, __BUILD_PKG__:false, __BUILD_GIT__, __BUILD_DATE__ */ import { Map, View } from 'ol'; import { transformExtent } from 'ol/proj.js'; import GeoJSON from 'ol/format/GeoJSON.js'; import { Tile as TileLayer, Vector as VectorLayer } from 'ol/layer.js'; import { XYZ, Vector as VectorSource, OSM } from 'ol/source.js'; import { defaults as defaultControls, ScaleLine } from 'ol/control.js'; import { Circle as CircleStyle, Text as TextStyle, Stroke, Style } from 'ol/style.js'; import { easeOut } from 'ol/easing.js'; import LayerSwitcher from 'ol-layerswitcher'; import 'bootstrap'; import 'bootstrap/dist/css/bootstrap.min.css'; import 'ol/ol.css'; import 'ol-layerswitcher/dist/ol-layerswitcher.css'; import './igc-xc-score.css'; import { solver as igcSolver, scoringRules as igcScoring } from 'igc-xc-score'; import { default as igcParser } from 'igc-parser'; const flightStyle = { 'flight': new Style({ stroke: new Stroke({ color: 'blue', width: 3 }) }), 'tp[0-9]': new Style({ image: new CircleStyle({ radius: 8, fill: null, stroke: new Stroke({ color: 'indigo', width: 6 }) }) }), 'ep_[start|finish]': new Style({ image: new CircleStyle({ radius: 8, fill: null, stroke: new Stroke({ color: 'darkslateblue', width: 6 }) }) }), 'cp_[in|out]': new Style({ image: new CircleStyle({ radius: 8, fill: null, stroke: new Stroke({ color: 'mediumvioletred', width: 4 }) }) }), 'launch': new Style({ image: new CircleStyle({ radius: 8, fill: null, stroke: new Stroke({ color: 'coral', width: 2 }) }), text: new TextStyle({ text: '>', stroke: new Stroke({ color: 'coral', width: 2 }) }) }), 'land': new Style({ image: new CircleStyle({ radius: 8, fill: null, stroke: new Stroke({ color: 'black', width: 2 }) }), text: new TextStyle({ text: '\\', stroke: new Stroke({ color: 'black', width: 2 }) }) }), 'seg[0-9|_in|_out]': new Style({ stroke: new Stroke({ color: 'indigo', width: 6 }) }), 'closing': new Style({ stroke: new Stroke({ color: 'mediumvioletred', width: 4 }) }), 'box0': new Style({ stroke: new Stroke({ color: 'white', width: 8 }), zIndex: 1000 }), 'box1': new Style({ stroke: new Stroke({ color: 'grey', width: 8 }), zIndex: 1000 }), 'box2': new Style({ stroke: new Stroke({ color: 'black', width: 8 }), zIndex: 1000 }) }; function styleGet(feature) { for (let s of Object.keys(flightStyle)) if (feature.getId().match(s)) return flightStyle[s]; } let flightDataSource; let geoJSONReader; function display(geojson, style) { flightDataSource.clear(); const features = geoJSONReader.readFeatures(geojson, { featureProjection: 'EPSG:3857' }); if (style) { for (const f of features) f.setStyle(style); } flightDataSource.addFeatures(features); } function coordinates(point) { return `${point.y.toFixed(4)}° : ${point.x.toFixed(4)}°`; } function loop() { const s = this.next(); if (!s.done) { $('#spinner').show(); if (__DEBUG__) { display(s.value.last.geojson()); } else { display(s.value.geojson()); } runningProcess = window.requestIdleCallback(loop.bind(this)); $('#status').html(`trying solutions, best so far is ${s.value.score.toFixed(2)} points` + `<p>theoretical best could be up to ${parseFloat(s.value.currentUpperBound).toFixed(2)} points`); } else { runningProcess = undefined; $('#spinner').hide(); display(s.value.geojson()); let r = [ `<td class="label">Best possible</td><td class="data">${s.value.score} points</td>`, `<td class="label">${s.value.opt.scoring.name}</td>` + `<td class="data">${s.value.scoreInfo.distance.toFixed(2)}km</td>` ]; if (s.value.scoreInfo.cp) r.push( `<td class="label">closing distance</td><td class="data">${s.value.scoreInfo.cp.d.toFixed(2)}km</td>`); if (s.value.scoreInfo.penalty) r.push(`<td class="label">penalty</td><td class="data">${s.value.scoreInfo.penalty.toFixed(2)}km</td>`); r.push(`<td class="label">multiplier</td><td class="data">${s.value.opt.scoring.multiplier}</td>`); for (const leg of s.value.scoreInfo.legs) r.push(`<td class="label">${leg.name}</td><td class="data">` + `${leg.d.toFixed(2)}km (${leg.start.distanceEarth(leg.finish).toFixed(3)}km)</td>`); if (s.value.scoreInfo.ep) r.push('<td class="label">START</td><td class="data">' + `${coordinates(s.value.scoreInfo.ep['start'])}</td>`); for (const i in s.value.scoreInfo.tp) r.push(`<td class="label">TP${i}</td><td class="data">` + `${coordinates(s.value.scoreInfo.tp[i])}</td>`); if (s.value.scoreInfo.ep) r.push('<td class="label">FINISH</td><td class="data">' + `${coordinates(s.value.scoreInfo.ep['finish'])}</td>`); $('#status').html('<table class="table"><tr>' + r.join('</tr><tr>')); } } let map; $(() => { $('#spinner').hide(); geoJSONReader = new GeoJSON(); flightDataSource = new VectorSource(); map = new Map({ target: 'map', layers: [ new TileLayer({ title: 'OpenTopoMap', visible: false, type: 'base', source: new XYZ({ url: 'https://{a-c}.tile.opentopomap.org/{z}/{x}/{y}.png', attributions: 'Kartendaten: © <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>-Mitwirkende, SRTM | Kartendarstellung: © <a href="http://opentopomap.org">OpenTopoMap</a> (<a href="https://creativecommons.org/licenses/by-sa/3.0/">CC-BY-SA</a>)', maxZoom: 17 }), }), new TileLayer({ title: 'OpenStreetMap', type: 'base', source: new OSM() }), new VectorLayer({ source: flightDataSource, style: styleGet }) ], view: new View({ center: [0, 0], zoom: 2, maxZoom: 18 }), controls: defaultControls().extend([ new ScaleLine({ units: 'metric', bar: true, steps: 5, text: false, minWidth: 140 }), new LayerSwitcher({ tipLabel: 'Map', groupSelectStyle: 'children' }) ]) }); }); if (!window.requestIdleCallback) { window.requestIdleCallback = (a) => { return setTimeout(a, 50); }; window.cancelIdleCallback = (a) => { clearTimeout(a); }; } let runningProcess; function runProcessing() { if (!igcFlight) return; if (runningProcess) { window.cancelIdleCallback(runningProcess); runningProcess = undefined; } const hp = $('#igc-hp').prop('checked'); const trim = $('#igc-trim').prop('checked'); window.requestIdleCallback(() => { const it = igcSolver(igcFlight, igcScoring[$('#igc-scoringRules').html()], { maxcycle: 100, hp: hp, debug: { debug: __DEBUG__ }, trim }, { timeout: 2000 }); loop.call(it); }); } $('#igc-xc-score-version').html(`${__BUILD_PKG__.name} ${__BUILD_PKG__.version} ${__BUILD_GIT__} ${__BUILD_DATE__} ${__DEBUG__ ? 'debug' : ''}`); Object.keys(igcScoring).map(scoring => { $('#igc-scoringRulesList').append(`<button class="dropdown-item ctrl-scoringRules" id="${scoring}">${scoring}</button>`); }); $('#igc-scoringRules').html(Object.keys(igcScoring)[0]); $('.ctrl-scoringRules').on('click', (event) => { $('#igc-scoringRules').html($(event.target).html()); runProcessing(); }); $('.ctrl-process').on('click', runProcessing); let igcFlight; $('#igc-upload').on('change', () => { const input = event.target; const reader = new FileReader(); reader.onload = function () { try { flightDataSource.clear(); $('#spinner').show(); const igcData = reader.result; $('#status').html(`igc loaded, ${igcData.length} bytes read`); igcFlight = igcParser.parse(igcData, { lenient: true }); $('#status').html(`igc parsed, ${igcFlight.fixes.length} GPS records found`); let minlat, maxlat, minlon, maxlon; [minlat, maxlat, minlon, maxlon] = [Infinity, -Infinity, Infinity, -Infinity]; for (let r of igcFlight.fixes) { minlat = Math.min(r.latitude, minlat); maxlat = Math.max(r.latitude, maxlat); minlon = Math.min(r.longitude, minlon); maxlon = Math.max(r.longitude, maxlon); } map.getView().fit(transformExtent([minlon, minlat, maxlon, maxlat], 'EPSG:4326', 'EPSG:3857'), { padding: [20, 20, 50, 20], duration: 1, easing: easeOut }); runProcessing(); } catch (e) { $('#status').html(encodeURIComponent(e.toString())); $('#spinner').hide(); igcFlight = undefined; } }; if (input.files[0]) reader.readAsBinaryString(input.files[0]); });