├── .editorconfig ├── .eslintrc.json ├── .github └── workflows │ └── test.yml ├── .gitignore ├── benchmark.js ├── build.js ├── example.js ├── index.js ├── license.md ├── package.json ├── readme.md └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | # Use tabs in JavaScript and JSON. 11 | [**.{js,json}] 12 | indent_style = tab 13 | indent_size = 4 14 | 15 | # Use spaces in YAML. 16 | [**.{yml,yaml}] 17 | indent_style = spaces 18 | indent_size = 2 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es2022": true, 5 | "node": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "ignorePatterns": [ 11 | "node_modules" 12 | ], 13 | "rules": { 14 | "no-unused-vars": [ 15 | "error", 16 | { 17 | "vars": "all", 18 | "args": "none", 19 | "ignoreRestSiblings": false 20 | } 21 | ] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: lint & test 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | jobs: 12 | lint-test: 13 | runs-on: ubuntu-latest 14 | strategy: 15 | matrix: 16 | node-version: ['18'] 17 | 18 | steps: 19 | - name: checkout 20 | uses: actions/checkout@v2 21 | - name: setup Node v${{ matrix.node-version }} 22 | uses: actions/setup-node@v1 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | - run: npm install 26 | 27 | - run: npm run lint 28 | - run: npm run build 29 | - run: npm test 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | 4 | .nvm-version 5 | node_modules 6 | npm-debug.log 7 | 8 | /package-lock.json 9 | 10 | original-ids.json 11 | tokens.json 12 | scores.json 13 | weights.json 14 | nr-of-tokens.json 15 | -------------------------------------------------------------------------------- /benchmark.js: -------------------------------------------------------------------------------- 1 | import _benchmark from 'benchmark' 2 | const {Suite} = _benchmark 3 | 4 | import {autocomplete} from './index.js' 5 | 6 | new Suite() 7 | 8 | .add('basic query, one token', function () { 9 | autocomplete('Leipzig', 3) 10 | }) 11 | .add('basic query, two tokens', function () { 12 | autocomplete('Dortmund Süd', 3) 13 | }) 14 | .add('no completion – "Münc Hbf"', function () { 15 | autocomplete('Münc Hbf', 3, false, false) 16 | }) 17 | .add('completion – "Münc Hbf"', function () { 18 | autocomplete('Münc Hbf', 3, false, true) 19 | }) 20 | .add('completion – "charlo"', function () { 21 | autocomplete('charlo', 3, false) 22 | }) 23 | .add('complex', function () { 24 | autocomplete('Calbe Saale Ost', 3) 25 | }) 26 | .add('non-fuzzy – "berlin charlottenburg"', function () { 27 | autocomplete('berlin charlottenburg', 3, false) 28 | }) 29 | .add('fuzzy – "berlin charlottenbrug" (typo)', function () { 30 | autocomplete('berlin charlottenbrug', 3, true) 31 | }) 32 | .add('100 results – "Münc"', function () { 33 | autocomplete('Münc', 100) 34 | }) 35 | 36 | .on('cycle', (e) => { 37 | console.log(e.target.toString()) 38 | }) 39 | .run() 40 | -------------------------------------------------------------------------------- /build.js: -------------------------------------------------------------------------------- 1 | import {dirname, join as pathJoin} from 'node:path' 2 | import {fileURLToPath} from 'node:url' 3 | import {writeFile} from 'node:fs/promises' 4 | import {readStations} from 'db-stations' 5 | import {buildIndex} from 'synchronous-autocomplete/build.js' 6 | import tokenize from 'tokenize-db-station-name' 7 | 8 | const __dirname = dirname(fileURLToPath(import.meta.url)) 9 | 10 | const writeJSON = async (file, data) => { 11 | await writeFile(pathJoin(__dirname, file), JSON.stringify(data)) 12 | } 13 | 14 | console.info('Collecting search items.') 15 | 16 | const items = [] 17 | for await (const station of readStations()) { 18 | items.push({ 19 | id: station.id, 20 | name: station.name, 21 | weight: station.weight 22 | }) 23 | } 24 | 25 | console.info('Computing a search index.') 26 | 27 | const {tokens, scores, weights, nrOfTokens, originalIds} = buildIndex(tokenize, items) 28 | 29 | console.info('Writing the index to disk.') 30 | 31 | await writeJSON('tokens.json', tokens) 32 | await writeJSON('scores.json', scores) 33 | await writeJSON('weights.json', weights) 34 | await writeJSON('nr-of-tokens.json', nrOfTokens) 35 | await writeJSON('original-ids.json', originalIds) 36 | -------------------------------------------------------------------------------- /example.js: -------------------------------------------------------------------------------- 1 | import {readStations} from 'db-stations' 2 | import prompt from 'cli-autocomplete' 3 | 4 | import {autocomplete} from './index.js' 5 | 6 | const stationsById = Object.create(null) 7 | 8 | for await (const s of readStations()) { 9 | stationsById[s.id] = s 10 | } 11 | 12 | const suggest = async (input) => { 13 | const results = autocomplete(input, 5) 14 | const choices = [] 15 | 16 | for (let result of results) { 17 | const station = stationsById[result.id] 18 | if (!station) continue 19 | 20 | choices.push({ 21 | title: [ 22 | station.name, 23 | '–', 24 | 'score:', result.score.toFixed(3), 25 | 'relevance:', result.relevance.toFixed(3), 26 | 'weight:', result.weight.toFixed(3), 27 | ].join(' '), 28 | value: station.id 29 | }) 30 | } 31 | 32 | return choices 33 | } 34 | 35 | prompt('Type a station name!', suggest) 36 | .once('submit', (id) => { 37 | console.log(id, stationsById[id]?.name) 38 | }) 39 | .once('abort', () => { 40 | process.exit(1) 41 | }) 42 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // todo: use import assertions once they're supported by Node.js & ESLint 2 | // https://github.com/tc39/proposal-import-assertions 3 | import {createRequire} from 'module' 4 | const require = createRequire(import.meta.url) 5 | 6 | import {createAutocomplete} from 'synchronous-autocomplete' 7 | import tokenize from 'tokenize-db-station-name' 8 | 9 | const tokens = require('./tokens.json') 10 | const scores = require('./scores.json') 11 | const weights = require('./weights.json') 12 | const nrOfTokens = require('./nr-of-tokens.json') 13 | const originalIds = require('./original-ids.json') 14 | 15 | const index = { 16 | tokens, 17 | scores, 18 | weights, 19 | nrOfTokens, 20 | originalIds, 21 | } 22 | const autocomplete = createAutocomplete(index, tokenize) 23 | 24 | export { 25 | index, 26 | autocomplete, 27 | } 28 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2022, Jannis R 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "db-stations-autocomplete", 3 | "description": "Search for stations of DB.", 4 | "version": "4.1.0", 5 | "type": "module", 6 | "main": "index.js", 7 | "files": [ 8 | "index.js", 9 | "original-ids.json", 10 | "tokens.json", 11 | "scores.json", 12 | "weights.json", 13 | "nr-of-tokens.json", 14 | "example.js" 15 | ], 16 | "keywords": [ 17 | "db", 18 | "deutsche bahn", 19 | "stations", 20 | "autocomplete", 21 | "search", 22 | "public transport", 23 | "open data" 24 | ], 25 | "author": "Jannis R ", 26 | "homepage": "https://github.com/derhuerst/db-stations-autocomplete", 27 | "repository": "git://github.com/derhuerst/db-stations-autocomplete.git", 28 | "license": "ISC", 29 | "engines": { 30 | "node": ">=18" 31 | }, 32 | "dependencies": { 33 | "synchronous-autocomplete": "^3.0.0", 34 | "tokenize-db-station-name": "^3.0.0" 35 | }, 36 | "devDependencies": { 37 | "benchmark": "^2.1.4", 38 | "cli-autocomplete": "^0.4.1", 39 | "db-stations": "^5.0.0", 40 | "eslint": "^8.30.0", 41 | "lodash": "^4.17.21", 42 | "tap-min": "^2.0.0", 43 | "tape": "^5.0.0" 44 | }, 45 | "scripts": { 46 | "lint": "eslint .", 47 | "build": "node ./build.js", 48 | "test": "node test.js | tap-min", 49 | "benchmark": "node benchmark.js", 50 | "prepublishOnly": "npm run lint && npm run build && npm test" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # db-stations-autocomplete 2 | 3 | *db-stations-autocomplete* provides a **stations search for [Deutsche Bahn](https://en.wikipedia.org/wiki/Deutsche_Bahn)**. Pulls its data from [`db-stations@4`](https://github.com/derhuerst/db-stations) (There's also [`db-hafas-stations-autocomplete`](https://github.com/derhuerst/db-hafas-stations-autocomplete), which pulls its data from [`db-hafas-stations`](https://github.com/derhuerst/db-hafas-stations)). 4 | 5 | [![npm version](https://img.shields.io/npm/v/db-stations-autocomplete.svg)](https://www.npmjs.com/package/db-stations-autocomplete) 6 | ![ISC-licensed](https://img.shields.io/github/license/derhuerst/db-stations-autocomplete.svg) 7 | ![minimum Node.js version](https://img.shields.io/node/v/db-stations-autocomplete.svg) 8 | [![support me via GitHub Sponsors](https://img.shields.io/badge/support%20me-donate-fa7664.svg)](https://github.com/sponsors/derhuerst) 9 | [![chat with me on Twitter](https://img.shields.io/badge/chat%20with%20me-on%20Twitter-1da1f2.svg)](https://twitter.com/derhuerst) 10 | 11 | 12 | ## Installing 13 | 14 | ```shell 15 | npm install db-stations-autocomplete 16 | ``` 17 | 18 | 19 | ## Usage 20 | 21 | ```js 22 | autocomplete(query, results = 3, fuzzy = false, completion = true) 23 | ``` 24 | 25 | ```javascript 26 | import {autocomplete} from 'db-stations-autocomplete' 27 | 28 | autocomplete('Münch', 3) 29 | ``` 30 | 31 | This returns stations in a reduced form of the [*Friendly Public Transport Format*](https://github.com/public-transport/friendly-public-transport-format). To get all details, pass use [`db-stations`](https://github.com/derhuerst/db-stations). 32 | 33 | ```javascript 34 | [ { 35 | id: '8000261', // München Hbf 36 | relevance: 0.8794466403162056, 37 | score: 11.763480191996974, 38 | weight: 2393.2 39 | }, { 40 | id: '8004128', // München Donnersbergerbrücke 41 | relevance: 0.8794466403162056, 42 | score: 9.235186720706798, 43 | weight: 1158 44 | }, { 45 | id: '8004132', // München Karlsplatz 46 | relevance: 0.8794466403162056, 47 | score: 9.144716179768407, 48 | weight: 1124.3 49 | } ] 50 | ``` 51 | 52 | If you set `fuzzy` to `true`, words with a [Levenshtein distance](https://en.wikipedia.org/wiki/Levenshtein_distance) `<= 3` will be taken into account. This is a lot slower though (Apple M1, Node v19.1): 53 | 54 | test | performance 55 | -----|------------ 56 | non-fuzzy – `berlin charlottenburg` | 704 ops/sec 57 | fuzzy – `berlin charlottenbrug` (note the typo) | 204 ops/sec 58 | 59 | 60 | Setting `completion` to `false` speeds things up: 61 | 62 | test | performance 63 | -----|------------ 64 | completion – `Münc Hbf` | 477 ops/sec 65 | no completion – `Münc Hbf` | 2115 ops/sec 66 | 67 | 68 | ## Contributing 69 | 70 | If you have a question or need support using `db-stations-autocomplete`, please double-check your code and setup first. If you think you have found a bug or want to propose a feature, use [the issues page](https://github.com/derhuerst/db-stations-autocomplete/issues). 71 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import sortBy from 'lodash/sortBy.js' 3 | 4 | import {autocomplete} from './index.js' 5 | 6 | test('autocomplete returns an array', (t) => { 7 | t.plan(2) 8 | t.ok(Array.isArray(autocomplete('', 3))) 9 | t.ok(Array.isArray(autocomplete('foo', 3))) 10 | }) 11 | 12 | test('autocomplete returns an empty array for an empty query', (t) => { 13 | t.plan(1) 14 | const results = autocomplete('', 3) 15 | 16 | t.equal(results.length, 0) 17 | }) 18 | 19 | test('autocomplete sorts by score', (t) => { 20 | t.plan(1) 21 | const results = autocomplete('berli', 3) 22 | 23 | t.deepEqual(results, sortBy(results, 'score').reverse()) 24 | }) 25 | 26 | // todo: fails with tokenize-db-station-name@2 27 | test.skip('autocomplete limits the number of results', (t) => { 28 | t.plan(1) 29 | t.equal(autocomplete('Hbf', 1).length, 1) 30 | }) 31 | 32 | test('gives reasonable results', (t) => { 33 | const r0 = autocomplete('Münch', 3) 34 | const münchenHbf = r0.find(({id}) => id === '8000261') 35 | t.ok(münchenHbf, 'missing "München Hbf"') 36 | const münchenOst = r0.find(({id}) => id === '8000262') 37 | t.ok(münchenOst, 'missing "München Ost"') 38 | 39 | const r1 = autocomplete('Berlin', 3) 40 | const berlinGesundbrunnen = r1.find(({id}) => id === '8011102') 41 | t.ok(berlinGesundbrunnen, 'missing "Berlin Gesundbrunnen"') 42 | 43 | const r3 = autocomplete('Karlsruhe', 1, true, false)[0] 44 | t.ok(r3) 45 | t.equal((r3 || {}).id, '8000191') // Karlsruhe 46 | 47 | const r4 = autocomplete('Wedding', 1)[0] 48 | t.ok(r4) 49 | t.equal((r4 || {}).id, '8089131') // Berlin Wedding 50 | 51 | t.end() 52 | }) 53 | --------------------------------------------------------------------------------