diff --git a/docs/changelog.md b/docs/changelog.md index 135c5d70..3bc5021a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,11 @@ # Changelog +## `2.7.0` + +- `journeys()`: `polylines` option +- `journeyLeg()`: `polyline` option +- `radar()`: `polylines` option + ## `2.6.0` - 5d10d76 journey legs: parse cycle diff --git a/docs/journey-leg.md b/docs/journey-leg.md index f54b0e6e..e21293c7 100644 --- a/docs/journey-leg.md +++ b/docs/journey-leg.md @@ -118,4 +118,66 @@ The response looked like this: } ``` -If you pass `polyline: true`, the leg will have a `polyline` field, containing an encoded shape. You can use e.g. [`@mapbox/polyline`](https://www.npmjs.com/package/@mapbox/polyline) to decode it. +### `polyline` option + +If you pass `polyline: true`, the leg will have a `polyline` field, containing a [GeoJSON](http://geojson.org) [`FeatureCollection`](https://tools.ietf.org/html/rfc7946#section-3.3) of [`Point`s](https://tools.ietf.org/html/rfc7946#appendix-A.1). Every `Point` next to a station will have `properties` containing the station's metadata. + +We'll look at an example for *U6* from *Alt-Mariendorf* to *Alt-Tegel*, taken from the [VBB profile](../p/vbb): + +```js +{ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + type: 'station', + id: '900000070301', + name: 'U Alt-Mariendorf', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.3875, 52.43993] // longitude, latitude + } + }, + /* … */ + { + type: 'Feature', + properties: { + type: 'station', + id: '900000017101', + name: 'U Mehringdamm', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.38892, 52.49448] // longitude, latitude + } + }, + /* … */ + { + // intermediate point, without associated station + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: [13.28599, 52.58742] // longitude, latitude + } + }, + { + type: 'Feature', + properties: { + type: 'station', + id: '900000089301', + name: 'U Alt-Tegel', + /* … */ + }, + geometry: { + type: 'Point', + coordinates: [13.28406, 52.58915] // longitude, latitude + } + } + ] +} +``` diff --git a/docs/journeys.md b/docs/journeys.md index 1b6d0051..016f052c 100644 --- a/docs/journeys.md +++ b/docs/journeys.md @@ -262,4 +262,4 @@ departure of last journey 2017-12-17T19:07:00.000+01:00 departure of first (later) journey 2017-12-17T19:19:00.000+01:00 ``` -If you pass `polylines: true`, each journey leg will have a `polyline` field, containing an encoded shape. You can use e.g. [`@mapbox/polyline`](https://www.npmjs.com/package/@mapbox/polyline) to decode it. +If you pass `polylines: true`, each journey leg will have a `polyline` field. Refer to [the section in the `journeyLeg()` docs](journey-leg.md#polyline-option) for details. diff --git a/docs/radar.md b/docs/radar.md index 0090b577..e42155c0 100644 --- a/docs/radar.md +++ b/docs/radar.md @@ -11,6 +11,7 @@ With `opt`, you can override the default options, which look like this: results: 256, // maximum number of vehicles duration: 30, // compute frames for the next n seconds frames: 3, // nr of frames to compute + polylines: false // return a track shape for each vehicle? } ``` @@ -161,3 +162,5 @@ The response may look like this: } ] }, /* … */ ] ``` + +If you pass `polylines: true`, each journey leg will have a `polyline` field, as documented in [the corresponding section in the `journeyLeg()` docs](journey-leg.md#polyline-option), with the exception that station info is missing. diff --git a/format/location.js b/format/location.js index 121cd13f..39f0a990 100644 --- a/format/location.js +++ b/format/location.js @@ -1,14 +1,15 @@ 'use strict' -const formatLocation = (profile, l) => { +const formatLocation = (profile, l, name = 'location') => { if ('string' === typeof l) return profile.formatStation(l) if ('object' === typeof l && !Array.isArray(l)) { if (l.type === 'station') return profile.formatStation(l.id) if ('string' === typeof l.id) return profile.formatPoi(l) if ('string' === typeof l.address) return profile.formatAddress(l) - throw new Error('invalid location type: ' + l.type) + if (!l.type) throw new Error(`missing ${name}.type`) + throw new Error(`invalid ${name}.type: ${l.type}`) } - throw new Error('valid station, address or poi required.') + throw new Error(name + ': valid station, address or poi required.') } module.exports = formatLocation diff --git a/format/products-filter.js b/format/products-filter.js index 8ee8ffca..e4252f8f 100644 --- a/format/products-filter.js +++ b/format/products-filter.js @@ -16,12 +16,14 @@ const createFormatProductsFilter = (profile) => { if (!isObj(filter)) throw new Error('products filter must be an object') filter = Object.assign({}, defaultProducts, filter) - let res = 0 + let res = 0, products = 0 for (let product in filter) { if (!hasProp(filter, product) || filter[product] !== true) continue if (!byProduct[product]) throw new Error('unknown product ' + product) + products++ for (let bitmask of byProduct[product].bitmasks) res += bitmask } + if (products === 0) throw new Error('no products used') return { type: 'PROD', diff --git a/index.js b/index.js index d8cc7734..69f831ea 100644 --- a/index.js +++ b/index.js @@ -31,7 +31,8 @@ const createClient = (profile, request = _request) => { direction: null, // only show departures heading to this station duration: 10 // show departures for the next n minutes }, opt) - opt.when = opt.when || new Date() + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') const products = profile.formatProductsFilter(opt.products || {}) const dir = opt.direction ? profile.formatStation(opt.direction) : null @@ -57,8 +58,8 @@ const createClient = (profile, request = _request) => { } const journeys = (from, to, opt = {}) => { - from = profile.formatLocation(profile, from) - to = profile.formatLocation(profile, to) + from = profile.formatLocation(profile, from, 'from') + to = profile.formatLocation(profile, to, 'to') if (('earlierThan' in opt) && ('laterThan' in opt)) { throw new Error('opt.laterThan and opt.laterThan are mutually exclusive.') @@ -95,8 +96,9 @@ const createClient = (profile, request = _request) => { tickets: false, // return tickets? polylines: false // return leg shapes? }, opt) - if (opt.via) opt.via = profile.formatLocation(profile, opt.via) - opt.when = opt.when || new Date() + if (opt.via) opt.via = profile.formatLocation(profile, opt.via, 'opt.via') + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') const filters = [ profile.formatProductsFilter(opt.products || {}) @@ -147,10 +149,7 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!Array.isArray(d.outConL)) return [] - let polylines = [] - if (opt.polylines && Array.isArray(d.common.polyL)) { - polylines = d.common.polyL - } + const polylines = opt.polylines && d.common.polyL || [] const parse = profile.parseJourney(profile, d.locations, d.lines, d.remarks, polylines) if (!journeys.earlierRef) journeys.earlierRef = d.outCtxScrB @@ -281,7 +280,8 @@ const createClient = (profile, request = _request) => { passedStations: true, // return stations on the way? polyline: false }, opt) - opt.when = opt.when || new Date() + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') return request(profile, { cfg: {polyEnc: 'GPA'}, @@ -295,10 +295,7 @@ const createClient = (profile, request = _request) => { } }) .then((d) => { - let polylines = [] - if (opt.polyline && Array.isArray(d.common.polyL)) { - polylines = d.common.polyL - } + const polylines = opt.polyline && d.common.polyL || [] const parse = profile.parseJourneyLeg(profile, d.locations, d.lines, d.remarks, polylines) const leg = { // pretend the leg is contained in a journey @@ -316,14 +313,18 @@ const createClient = (profile, request = _request) => { if ('number' !== typeof west) throw new Error('west must be a number.') if ('number' !== typeof south) throw new Error('south must be a number.') if ('number' !== typeof east) throw new Error('east must be a number.') + if (north <= south) throw new Error('north must be larger than south.') + if (east <= west) throw new Error('east must be larger than west.') opt = Object.assign({ results: 256, // maximum number of vehicles duration: 30, // compute frames for the next n seconds frames: 3, // nr of frames to compute - products: null // optionally an object of booleans + products: null, // optionally an object of booleans + polylines: false // return a track shape for each vehicle? }, opt || {}) - opt.when = opt.when || new Date() + opt.when = new Date(opt.when || Date.now()) + if (Number.isNaN(+opt.when)) throw new Error('opt.when is invalid') const durationPerStep = opt.duration / Math.max(opt.frames, 1) * 1000 return request(profile, { @@ -347,7 +348,8 @@ const createClient = (profile, request = _request) => { .then((d) => { if (!Array.isArray(d.jnyL)) return [] - const parse = profile.parseMovement(profile, d.locations, d.lines, d.remarks) + const polylines = opt.polyline && d.common.polyL || [] + const parse = profile.parseMovement(profile, d.locations, d.lines, d.remarks, polylines) return d.jnyL.map(parse) }) } diff --git a/lib/default-profile.js b/lib/default-profile.js index 340de25f..57d7eb36 100644 --- a/lib/default-profile.js +++ b/lib/default-profile.js @@ -6,6 +6,7 @@ const parseJourneyLeg = require('../parse/journey-leg') const parseJourney = require('../parse/journey') const parseLine = require('../parse/line') const parseLocation = require('../parse/location') +const parsePolyline = require('../parse/polyline') const parseMovement = require('../parse/movement') const parseNearby = require('../parse/nearby') const parseOperator = require('../parse/operator') @@ -42,6 +43,7 @@ const defaultProfile = { parseLine, parseStationName: id, parseLocation, + parsePolyline, parseMovement, parseNearby, parseOperator, diff --git a/lib/request.js b/lib/request.js index 084fe26b..3cde8fbb 100644 --- a/lib/request.js +++ b/lib/request.js @@ -1,6 +1,6 @@ 'use strict' -const crypto = require('crypto') +const createHash = require('create-hash') let captureStackTrace = () => {} if (process.env.NODE_DEBUG === 'hafas-client') { captureStackTrace = require('capture-stack-trace') @@ -9,7 +9,7 @@ const {stringify} = require('query-string') const Promise = require('pinkie-promise') const {fetch} = require('fetch-ponyfill')({Promise}) -const md5 = input => crypto.createHash('md5').update(input).digest() +const md5 = input => createHash('md5').update(input).digest() const request = (profile, data) => { const body = profile.transformReqBody({lang: 'en', svcReqL: [data]}) diff --git a/lib/validate-profile.js b/lib/validate-profile.js index 21c86829..88e3d0c1 100644 --- a/lib/validate-profile.js +++ b/lib/validate-profile.js @@ -16,6 +16,7 @@ const types = { parseLine: 'function', parseStationName: 'function', parseLocation: 'function', + parsePolyline: 'function', parseMovement: 'function', parseNearby: 'function', parseOperator: 'function', diff --git a/p/db/index.js b/p/db/index.js index e43c00b1..b8b4fc60 100644 --- a/p/db/index.js +++ b/p/db/index.js @@ -10,7 +10,7 @@ const formatLoyaltyCard = require('./loyalty-cards').format const transformReqBody = (body) => { body.client = {id: 'DB', v: '16040000', type: 'IPH', name: 'DB Navigator'} body.ext = 'DB.R15.12.a' - body.ver = '1.15' + body.ver = '1.16' body.auth = {type: 'AID', aid: 'n91dB8Z77MLdoR0K'} return body @@ -34,8 +34,8 @@ const transformJourneysQuery = (query, opt) => { return query } -const createParseJourney = (profile, stations, lines, remarks) => { - const parseJourney = _createParseJourney(profile, stations, lines, remarks) +const createParseJourney = (profile, stations, lines, remarks, polylines) => { + const parseJourney = _createParseJourney(profile, stations, lines, remarks, polylines) // todo: j.sotRating, j.conSubscr, j.isSotCon, j.showARSLink, k.sotCtxt // todo: j.conSubscr, j.showARSLink, j.useableTime @@ -102,7 +102,7 @@ const dbProfile = { formatStation, - journeyLeg: true + journeyLeg: true // todo: #49 } module.exports = dbProfile diff --git a/package.json b/package.json index d6164f95..ad545fd1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "hafas-client", "description": "JavaScript client for HAFAS public transport APIs.", - "version": "2.6.0", + "version": "2.7.3", "main": "index.js", "files": [ "index.js", @@ -32,8 +32,11 @@ "node": ">=6" }, "dependencies": { + "@mapbox/polyline": "^1.0.0", "capture-stack-trace": "^1.0.0", + "create-hash": "^1.2.0", "fetch-ponyfill": "^6.0.0", + "gps-distance": "0.0.4", "lodash": "^4.17.5", "luxon": "^0.5.8", "p-throttle": "^1.1.0", diff --git a/parse/journey-leg.js b/parse/journey-leg.js index fd634a9f..bbc3f9da 100644 --- a/parse/journey-leg.js +++ b/parse/journey-leg.js @@ -36,9 +36,10 @@ const createParseJourneyLeg = (profile, stations, lines, remarks, polylines) => if (pt.jny && pt.jny.polyG) { let p = pt.jny.polyG.polyXL - p = p && polylines[p[0]] + p = Array.isArray(p) && polylines[p[0]] // todo: there can be >1 polyline - res.polyline = p && p.crdEncYX || null + const parse = profile.parsePolyline(stations) + res.polyline = p && parse(p) || null } if (pt.type === 'WALK') { diff --git a/parse/location.js b/parse/location.js index 1659ea92..44f34f5c 100644 --- a/parse/location.js +++ b/parse/location.js @@ -19,7 +19,7 @@ const parseLocation = (profile, l, lines) => { const station = { type: 'station', id: l.extId, - name: profile.parseStationName(l.name), + name: l.name ? profile.parseStationName(l.name) : null, location: 'number' === typeof res.latitude ? res : null } diff --git a/parse/movement.js b/parse/movement.js index 4a006026..8a3ab401 100644 --- a/parse/movement.js +++ b/parse/movement.js @@ -1,18 +1,18 @@ 'use strict' -const createParseMovement = (profile, locations, lines, remarks) => { +const createParseMovement = (profile, locations, lines, remarks, polylines = []) => { // todo: what is m.dirGeo? maybe the speed? // todo: what is m.stopL? // todo: what is m.proc? wut? // todo: what is m.pos? // todo: what is m.ani.dirGeo[n]? maybe the speed? // todo: what is m.ani.proc[n]? wut? - // todo: how does m.ani.poly work? const parseMovement = (m) => { const pStopover = profile.parseStopover(profile, locations, lines, remarks, m.date) const res = { direction: profile.parseStationName(m.dirTxt), + journeyId: m.jid || null, trip: m.jid && +m.jid.split('|')[1] || null, // todo: this seems brittle line: lines[m.prodX] || null, location: m.pos ? { @@ -24,13 +24,26 @@ const createParseMovement = (profile, locations, lines, remarks) => { frames: [] } - if (m.ani && Array.isArray(m.ani.mSec)) { - for (let i = 0; i < m.ani.mSec.length; i++) { - res.frames.push({ - origin: locations[m.ani.fLocX[i]] || null, - destination: locations[m.ani.tLocX[i]] || null, - t: m.ani.mSec[i] - }) + if (m.ani) { + if (Array.isArray(m.ani.mSec)) { + for (let i = 0; i < m.ani.mSec.length; i++) { + res.frames.push({ + origin: locations[m.ani.fLocX[i]] || null, + destination: locations[m.ani.tLocX[i]] || null, + t: m.ani.mSec[i] + }) + } + } + + if (m.ani.poly) { + const parse = profile.parsePolyline(locations) + res.polyline = parse(m.ani.poly) + } else if (m.ani.polyG) { + let p = m.ani.polyG.polyXL + p = Array.isArray(p) && polylines[p[0]] + // todo: there can be >1 polyline + const parse = profile.parsePolyline(locations) + res.polyline = p && parse(p) || null } } diff --git a/parse/polyline.js b/parse/polyline.js new file mode 100644 index 00000000..5cdcff0c --- /dev/null +++ b/parse/polyline.js @@ -0,0 +1,53 @@ +'use strict' + +const {toGeoJSON} = require('@mapbox/polyline') +const distance = require('gps-distance') + +const createParsePolyline = (locations) => { + // todo: what is p.delta? + // todo: what is p.type? + // todo: what is p.crdEncS? + // todo: what is p.crdEncF? + const parsePolyline = (p) => { + const shape = toGeoJSON(p.crdEncYX) + if (shape.coordinates.length === 0) return null + + const res = shape.coordinates.map(crd => ({ + type: 'Feature', + properties: {}, + geometry: { + type: 'Point', + coordinates: crd + } + })) + + if (Array.isArray(p.ppLocRefL)) { + for (let ref of p.ppLocRefL) { + const p = res[ref.ppIdx] + const loc = locations[ref.locX] + if (p && loc) p.properties = loc + } + + // Often there is one more point right next to each point at a station. + // We filter them here if they are < 5m from each other. + for (let i = 1; i < res.length; i++) { + const p1 = res[i - 1].geometry.coordinates + const p2 = res[i].geometry.coordinates + const d = distance(p1[1], p1[0], p2[1], p2[0]) + if (d >= .005) continue + const l1 = Object.keys(res[i - 1].properties).length + const l2 = Object.keys(res[i].properties).length + if (l1 === 0 && l2 > 0) res.splice(i - 1, 1) + else if (l2 === 0 && l1 > 0) res.splice(i, 1) + } + } + + return { + type: 'FeatureCollection', + features: res + } + } + return parsePolyline +} + +module.exports = createParsePolyline diff --git a/readme.md b/readme.md index d5b042ef..70e885b1 100644 --- a/readme.md +++ b/readme.md @@ -13,7 +13,7 @@ HAFAS endpoint | wrapper library | docs | example code | source code [![npm version](https://img.shields.io/npm/v/hafas-client.svg)](https://www.npmjs.com/package/hafas-client) [![build status](https://img.shields.io/travis/public-transport/hafas-client.svg)](https://travis-ci.org/public-transport/hafas-client) ![ISC-licensed](https://img.shields.io/github/license/public-transport/hafas-client.svg) -[![chat on gitter](https://badges.gitter.im/derhuerst.svg)](https://gitter.im/derhuerst) +[![chat on gitter](https://badges.gitter.im/public-transport/Lobby.svg)](https://gitter.im/public-transport/Lobby) [![support me on Patreon](https://img.shields.io/badge/support%20me-on%20patreon-fa7664.svg)](https://patreon.com/derhuerst)