├── src ├── vite-env.d.ts ├── components │ ├── maptrip.css │ ├── MatchSettings.css │ ├── help.css │ ├── MatchDetails.css │ ├── match-details-trip-list.css │ ├── gtfsload.css │ ├── Changes.css │ ├── map.css │ ├── OsmTags.css │ ├── icon-check-box.css │ ├── RouteMatch.css │ ├── match-list.css │ ├── IconCheckBox.tsx │ ├── StopMoveController.tsx │ ├── StopListElement.tsx │ ├── NewStopController.tsx │ ├── RematchController.tsx │ ├── MapTrip.tsx │ ├── QueryOSM.tsx │ ├── RouteTripsEditor.tsx │ ├── Changes.tsx │ ├── MapMatchMarker.tsx │ ├── HelpMarkdown.tsx │ ├── GTFSLoad.tsx │ ├── OpenCurentViewInJosm.tsx │ ├── MatchEditor.tsx │ ├── MatchDetailsTripList.tsx │ ├── MatchSettings.tsx │ ├── MatchDetails.tsx │ ├── Map.tsx │ ├── OsmTags.tsx │ ├── MatchList.tsx │ └── RouteMatch.tsx ├── index.css ├── models │ ├── MercatorUtil.ts │ ├── StopMatchesSequence.ts │ ├── OsmRoute.ts │ ├── BBOX.ts │ ├── OsmStop.ts │ ├── Filters.ts │ ├── Editor.ts │ └── GTFSData.ts ├── main.tsx ├── help │ ├── HelpStopTypes.md │ ├── HelpRefCode.md │ ├── HelpNameTemplate.md │ └── example.osm ├── services │ ├── Matcher.types.ts │ ├── utils.ts │ ├── OSMData.types.ts │ ├── JOSMRemote.ts │ ├── OSMData.ts │ └── Matcher.ts ├── App.css ├── assets │ └── react.svg └── App.tsx ├── dist ├── images │ ├── layers.png │ ├── layers-2x.png │ ├── marker-icon.png │ ├── marker-shadow.png │ └── marker-icon-2x.png └── index.html ├── public ├── images │ ├── layers.png │ ├── layers-2x.png │ ├── marker-icon.png │ ├── marker-shadow.png │ └── marker-icon-2x.png └── index.html ├── .babelrc ├── tsconfig.node.json ├── vite.config.ts ├── README.md ├── .gitignore ├── index.html ├── .eslintrc.cjs ├── tsconfig.json ├── LICENSE.md ├── package.json └── rollup.config.js /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/components/maptrip.css: -------------------------------------------------------------------------------- 1 | .trip-path { 2 | stroke-dasharray: 1 12 6; 3 | } -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | padding: 0; 4 | margin: 0; 5 | } -------------------------------------------------------------------------------- /dist/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/dist/images/layers.png -------------------------------------------------------------------------------- /public/images/layers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/public/images/layers.png -------------------------------------------------------------------------------- /dist/images/layers-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/dist/images/layers-2x.png -------------------------------------------------------------------------------- /src/components/MatchSettings.css: -------------------------------------------------------------------------------- 1 | .match-settings { 2 | margin-top: 1em; 3 | margin-bottom: 1em; 4 | } -------------------------------------------------------------------------------- /dist/images/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/dist/images/marker-icon.png -------------------------------------------------------------------------------- /dist/images/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/dist/images/marker-shadow.png -------------------------------------------------------------------------------- /public/images/layers-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/public/images/layers-2x.png -------------------------------------------------------------------------------- /public/images/marker-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/public/images/marker-icon.png -------------------------------------------------------------------------------- /src/components/help.css: -------------------------------------------------------------------------------- 1 | div.help { 2 | width: 400px; 3 | } 4 | 5 | .help code { 6 | color: #6B4630 7 | } -------------------------------------------------------------------------------- /dist/images/marker-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/dist/images/marker-icon-2x.png -------------------------------------------------------------------------------- /public/images/marker-shadow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/public/images/marker-shadow.png -------------------------------------------------------------------------------- /public/images/marker-icon-2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kiselev-dv/osm-gtfs/HEAD/public/images/marker-icon-2x.png -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-react"], 3 | "env": { 4 | "development": { 5 | "plugins": ["react-refresh/babel"] 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /src/components/MatchDetails.css: -------------------------------------------------------------------------------- 1 | .match-details { 2 | max-height: 45%; 3 | overflow-y: auto; 4 | } 5 | 6 | .match-details button + button { 7 | margin-left: 0.5em; 8 | } -------------------------------------------------------------------------------- /src/components/match-details-trip-list.css: -------------------------------------------------------------------------------- 1 | .trip-name { 2 | margin-inline-start: 0.25em; 3 | cursor: pointer; 4 | } 5 | 6 | .route-name { 7 | margin-inline-start: 0.25em; 8 | } -------------------------------------------------------------------------------- /src/components/gtfsload.css: -------------------------------------------------------------------------------- 1 | .dropzone { 2 | width: 400px; 3 | border-radius: 5px; 4 | border: 1px solid burlywood; 5 | margin-bottom: 10px; 6 | text-align: center; 7 | cursor: pointer; 8 | } 9 | -------------------------------------------------------------------------------- /src/models/MercatorUtil.ts: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | 3 | const merc = L.Projection.SphericalMercator; 4 | 5 | export function lonLatToMerc(lng: number, lat: number) { 6 | return merc.project({lat, lng}); 7 | } -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import App from './App.tsx' 4 | import './index.css' 5 | 6 | ReactDOM.createRoot(document.getElementById('root')!).render( 7 | 8 | 9 | , 10 | ) 11 | -------------------------------------------------------------------------------- /src/help/HelpStopTypes.md: -------------------------------------------------------------------------------- 1 | Stops have different types in OSM: 2 | 3 | Supported stops tagging schemes: 4 | * n Bus stop (`highway=bus_stop`) 5 | * n Trolleybus stop (`highway=bus_stop`) 6 | * n Tram stop (`railway=tram_stop`) 7 | * nw `public_transport=platform` 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig } from 'vite' 3 | import { nodePolyfills } from 'vite-plugin-node-polyfills' 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [ 8 | react(), 9 | nodePolyfills() 10 | ], 11 | }) 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Install 2 | ```bash 3 | yarn 4 | ``` 5 | 6 | ## Dev server 7 | ```bash 8 | yarn dev 9 | ``` 10 | 11 | ## OSM PT V2 Route 12 | 13 | https://wiki.openstreetmap.org/w/index.php?oldid=625726#Route_Direction_/_Variant 14 | 15 | 16 | ## Deployed version 17 | 18 | https://kiselev-dv.github.io/osm-gtfs/ 19 | -------------------------------------------------------------------------------- /src/components/Changes.css: -------------------------------------------------------------------------------- 1 | 2 | .changes-tab span { 3 | margin-left: 0.25em; 4 | margin-right: 0.25em; 5 | } 6 | 7 | .change-element-id { 8 | width: 5.5em; 9 | display: inline-block; 10 | } 11 | .change-element-type { 12 | width: 2em; 13 | display: inline-block; 14 | } 15 | .changes-tab button + button { 16 | margin-left: 0.5em; 17 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/components/map.css: -------------------------------------------------------------------------------- 1 | #map { 2 | min-height: 400px; 3 | height: 100%; 4 | } 5 | 6 | #map.darken .leaflet-tile { 7 | filter: saturate(0.25) brightness(0.5); 8 | } 9 | 10 | #map .ll-darken-control { 11 | background-color: white; 12 | padding: 0.25em; 13 | border: 2px solid rgba(0,0,0,0.2); 14 | border-radius: 5px; 15 | color: rgb(51, 51, 51); 16 | } -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/help/HelpRefCode.md: -------------------------------------------------------------------------------- 1 | First step is to match stops from GTFS with stops in OSM. 2 | 3 | I count stop matched if OSM version has GTFS stop 4 | code or id in it's tags. There is no particular 5 | tag which would fit in all situations, 6 | so this tag is configurable. 7 | 8 | Some examples: 9 | * `ref` 10 | * `gtfs:ref` 11 | * `:ref` 12 | 13 | After OSM data query is loaded, you can 14 | find OSM tags statisc for tags containing 15 | `ref` or `gtfs` above. -------------------------------------------------------------------------------- /src/help/HelpNameTemplate.md: -------------------------------------------------------------------------------- 1 | For matched stops, it might be usefull to edit 2 | names accordingly to a common pattern. 3 | 4 | You can use a template to edit stop names: 5 | 6 | Use `$name`, `$code`, `$id`, `$description` to substitute corresponding GTFS values. 7 | 8 | You can apply regexp replace patterns to substituted values: 9 | `$name.re('', '')` 10 | 11 | For instance if you want to remove text in brackets from `$name` 12 | use `$name.re('(.*)', '')` -------------------------------------------------------------------------------- /src/components/OsmTags.css: -------------------------------------------------------------------------------- 1 | .tag-actions span.material-icons { 2 | font-size: 1em; 3 | color: lightgrey; 4 | } 5 | 6 | .tag-actions span.material-icons:hover { 7 | color: inherit; 8 | cursor: pointer; 9 | } 10 | 11 | .osm-tags-table input { 12 | border: 1px solid #666; 13 | } 14 | 15 | .protected input { 16 | background-color: #ddd; 17 | } 18 | 19 | .invalid input { 20 | background-color: #dc9e9e; 21 | } 22 | 23 | .osm-tag-delete span:hover { 24 | color: red; 25 | } -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | OSM-GTFS Editor 7 | 8 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'plugin:react-hooks/recommended', 8 | ], 9 | ignorePatterns: ['dist', '.eslintrc.cjs'], 10 | parser: '@typescript-eslint/parser', 11 | plugins: ['react-refresh'], 12 | rules: { 13 | 'react-refresh/only-export-components': [ 14 | 'warn', 15 | { allowConstantExport: true }, 16 | ], 17 | }, 18 | } 19 | -------------------------------------------------------------------------------- /dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /src/models/StopMatchesSequence.ts: -------------------------------------------------------------------------------- 1 | import { StopMatchData } from "../services/Matcher"; 2 | import { StopMatch } from "../services/Matcher.types"; 3 | import { GTFSTripUnion } from "./GTFSData"; 4 | 5 | export class StopMatchesSequence { 6 | 7 | tripUnion: GTFSTripUnion 8 | stopMatchSequence: StopMatch[] 9 | 10 | constructor(tripUnion: GTFSTripUnion, matchData: StopMatchData) { 11 | this.tripUnion = tripUnion; 12 | 13 | this.stopMatchSequence = this.tripUnion.stopSequence.map( 14 | gtfsStop => matchData.matchByGtfsId[gtfsStop.id] 15 | ); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/components/icon-check-box.css: -------------------------------------------------------------------------------- 1 | /* For whatever reason this doesn't work, it's included static way in index.html */ 2 | @import url('https://fonts.googleapis.com/icon?family=Material+Icons'); 3 | 4 | .icon-check-box { 5 | cursor: pointer; 6 | user-select: none; 7 | vertical-align: middle; 8 | font-size: 1.25em; 9 | 10 | display: inline-block; 11 | border-radius: 0.1em; 12 | 13 | color: black; 14 | background-color: unset; 15 | } 16 | 17 | .icon-check-box.checked { 18 | font-size: 0.9em; 19 | background-color: #0075ff; 20 | color: white; 21 | margin-inline-end: 0.2em; 22 | margin-inline-start: 0.2em; 23 | } 24 | 25 | .icon-check-box.checked:hover { 26 | background-color: #015dc6; 27 | } 28 | -------------------------------------------------------------------------------- /src/components/RouteMatch.css: -------------------------------------------------------------------------------- 1 | 2 | .trips-info { 3 | font-size: 0.8em; 4 | margin-bottom: 0.5em; 5 | } 6 | 7 | .trips-info-header { 8 | margin-bottom: 0.25em; 9 | } 10 | 11 | .route-match span + span { 12 | margin-left: 0.4em; 13 | } 14 | 15 | .trips-info span + span { 16 | margin-left: 0.5em; 17 | } 18 | 19 | .route-match-gtfs-name { 20 | margin-bottom: 0.25em; 21 | } 22 | 23 | .match-count { 24 | font-size: 0.8em; 25 | } 26 | 27 | .match-count.all-matched { 28 | color: green; 29 | } 30 | 31 | .trips-h2 { 32 | margin-top: 0.4em; 33 | margin-bottom: 0.1em; 34 | } 35 | 36 | .selectable { 37 | cursor: pointer; 38 | } 39 | 40 | .routes-filters { 41 | padding-bottom: 1em; 42 | } 43 | 44 | .routes-list { 45 | border-top: 1px solid black; 46 | } -------------------------------------------------------------------------------- /src/components/match-list.css: -------------------------------------------------------------------------------- 1 | .match-list { 2 | margin-top: 1em; 3 | padding-top: 0.5em; 4 | 5 | height: 40%; 6 | 7 | flex: 1 0 auto; 8 | display: flex; 9 | flex-direction: column; 10 | } 11 | 12 | .stop-match { 13 | cursor: pointer; 14 | } 15 | .stop-match.selected { 16 | font-weight: bold; 17 | } 18 | .match-list-filters label { 19 | margin-inline-end: 0.25em; 20 | } 21 | .match-list-filters input[type="checkbox"] { 22 | vertical-align: baseline; 23 | } 24 | .match-mark { 25 | display: inline-block; 26 | border-radius: 0.3em; 27 | width: 0.6em; 28 | height: 0.6em; 29 | vertical-align: -20%; 30 | margin-right: 0.25em; 31 | margin-left: 0.25em; 32 | } 33 | 34 | .match-list .scroll-pane { 35 | margin-top: 0.5em; 36 | border-top: 1px solid #999; 37 | } 38 | -------------------------------------------------------------------------------- /src/services/Matcher.types.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @typedef {Object} StopMatch 3 | * @property {string} id 4 | * @property {OsmStop} osmStop 5 | * @property {GTFSStop} gtfsStop 6 | * @property {Object} codeMatch 7 | */ 8 | 9 | import { GTFSRoute, GTFSStop } from "../models/GTFSData"; 10 | import { OsmRoute } from "../models/OsmRoute"; 11 | import OsmStop from "../models/OsmStop"; 12 | 13 | export type StopMatch = { 14 | id: string 15 | osmStop?: OsmStop 16 | gtfsStop?: GTFSStop 17 | codeMatch?: { 18 | stopPosition: boolean 19 | platform: boolean 20 | } 21 | }; 22 | 23 | export type RouteMatchType = { 24 | osmRoute?: OsmRoute 25 | gtfsRoute?: GTFSRoute 26 | }; 27 | 28 | export type MatchSettingsType = { 29 | refTag: string 30 | matchByName: boolean 31 | matchByCodeInName: boolean 32 | }; 33 | -------------------------------------------------------------------------------- /src/components/IconCheckBox.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useCallback } from 'react'; 3 | 4 | import './icon-check-box.css'; 5 | 6 | type checkedCB = (selected?: boolean) => void; 7 | 8 | export type IconCheckBoxProps = { 9 | icon: string 10 | alt?: string 11 | className?: string 12 | checked?: boolean 13 | onChange?: checkedCB 14 | }; 15 | export default function IconCheckBox( 16 | {icon, alt, className, checked, onChange}: IconCheckBoxProps 17 | ) { 18 | 19 | const cssClass = classNames('material-icons', 'icon-check-box', className, {checked: checked}); 20 | 21 | const handleClick = useCallback(() => { 22 | onChange && onChange(checked === undefined ? undefined : !checked); 23 | }, [checked, onChange]); 24 | 25 | return { icon } 26 | 27 | } -------------------------------------------------------------------------------- /src/components/StopMoveController.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | 3 | import { LeafletMouseEventHandlerFn } from 'leaflet'; 4 | import { doneEditCB } from '../models/Editor'; 5 | import { MapContext } from './Map'; 6 | 7 | export type StopMoveControllerProps = { 8 | doneEdit: doneEditCB 9 | }; 10 | export default function StopMoveController({doneEdit}: StopMoveControllerProps) { 11 | 12 | const map = useContext(MapContext); 13 | 14 | useEffect(() => { 15 | if (map) { 16 | const clickHandler: LeafletMouseEventHandlerFn = ({latlng}) => { 17 | doneEdit && doneEdit({latlng}); 18 | }; 19 | 20 | map.on('click', clickHandler); 21 | 22 | return () => { 23 | map.off('click', clickHandler); 24 | }; 25 | } 26 | }, [map, doneEdit]); 27 | 28 | return <> 29 | } -------------------------------------------------------------------------------- /src/services/utils.ts: -------------------------------------------------------------------------------- 1 | import { TagStatistics } from "./OSMData"; 2 | 3 | export function filterTagStatsByRe(tags: TagStatistics, re: RegExp) { 4 | const result = {}; 5 | // @ts-ignore 6 | for (const [key, value] of tags) { 7 | if (re.test(key)) { 8 | // @ts-ignore 9 | result[key] = value; 10 | } 11 | } 12 | return result as TagStatistics; 13 | } 14 | 15 | export type tagCB = (tag: string) => void; 16 | 17 | export function findMostPopularTag(refTags: TagStatistics, treshold: number, callback: tagCB) { 18 | if (refTags && callback) { 19 | const mostPopular = Object.entries(refTags).sort((e1, e2) => e2[1] - e1[1])[0]; 20 | if (mostPopular) { 21 | const [tagKey, tagCount] = mostPopular; 22 | if (tagCount > treshold) { 23 | callback(tagKey); 24 | } 25 | } 26 | } 27 | } 28 | 29 | -------------------------------------------------------------------------------- /src/models/OsmRoute.ts: -------------------------------------------------------------------------------- 1 | import { OSMRelation } from "../services/OSMData.types"; 2 | 3 | export class OsmRoute { 4 | 5 | ref: string 6 | name: string 7 | 8 | tripRelations: OSMRelation[] 9 | masterRelation?: OSMRelation 10 | 11 | constructor(ref: string, relations: OSMRelation[]) { 12 | this.ref = ref; 13 | 14 | this.tripRelations = []; 15 | 16 | relations.forEach(r => { 17 | if (r.tags.type === 'route_master') { 18 | if (this.masterRelation) { 19 | console.warn('Multiple master relations', this.masterRelation, r); 20 | } 21 | 22 | this.masterRelation = r; 23 | } 24 | else { 25 | this.tripRelations.push(r); 26 | } 27 | }); 28 | 29 | this.name = this.masterRelation?.tags?.name || this.tripRelations[0]?.tags?.name; 30 | } 31 | 32 | } -------------------------------------------------------------------------------- /src/services/OSMData.types.ts: -------------------------------------------------------------------------------- 1 | export type OSMElementType = "node" | "way" | "relation"; 2 | 3 | export type OSMElementTags = { 4 | [key: string]: string 5 | } 6 | 7 | export type OSMElement = OSMNode | OSMWay | OSMRelation; 8 | 9 | export type OSMNode = { 10 | id: number 11 | type: "node" 12 | tags: OSMElementTags 13 | 14 | lon: number 15 | lat: number 16 | } 17 | 18 | export type OSMWay = { 19 | id: number 20 | type: "way" 21 | tags: OSMElementTags 22 | 23 | nodes: number[] 24 | } 25 | 26 | export type OSMRelationMember = { 27 | ref: number 28 | type: OSMElementType 29 | role?: string 30 | } 31 | 32 | export type OSMRelation = { 33 | id: number 34 | type: "relation" 35 | tags: OSMElementTags 36 | 37 | members: OSMRelationMember[] 38 | } 39 | 40 | export type LonLatTuple = [lon:number, lat: number]; 41 | export type LonLat = {lon:number, lat: number}; 42 | 43 | -------------------------------------------------------------------------------- /src/components/StopListElement.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | 3 | import { StopMatch } from '../services/Matcher.types'; 4 | import { getMatchColor } from './MapMatchMarker'; 5 | 6 | export type StopListElementProps = { 7 | match: StopMatch 8 | selected: boolean 9 | onClick: (evnt: React.MouseEvent) => void 10 | }; 11 | export default function StopListElement({match, selected, onClick}: StopListElementProps) { 12 | const osmStop = match.osmStop; 13 | const gtfsStop = match.gtfsStop; 14 | 15 | const name = osmStop?.getName() || gtfsStop?.name || osmStop?.getId(); 16 | const className = classNames( 17 | 'stop-match', { 18 | 'matched': !!osmStop && !!gtfsStop, 19 | 'selected': selected 20 | } 21 | ); 22 | 23 | const color = getMatchColor(match); 24 | 25 | return (
26 |  { name } 27 |
); 28 | } -------------------------------------------------------------------------------- /src/components/NewStopController.tsx: -------------------------------------------------------------------------------- 1 | import { LeafletMouseEventHandlerFn } from "leaflet"; 2 | import { useCallback, useContext, useEffect } from "react"; 3 | import { EditSubjectType, doneEditCB } from "../models/Editor"; 4 | import { MapContext, layers } from "./Map"; 5 | 6 | export type NewStopControllerProp = { 7 | editSubj: EditSubjectType 8 | doneEdit: doneEditCB 9 | }; 10 | export default function NewStopController({doneEdit}: NewStopControllerProp) { 11 | 12 | const map = useContext(MapContext); 13 | 14 | const mapClick = useCallback(({latlng}) => { 15 | doneEdit && doneEdit({latlng}) 16 | }, [doneEdit, map]); 17 | 18 | useEffect(() => { 19 | if (map) { 20 | layers.sat.addTo(map); 21 | 22 | map.on('click', mapClick); 23 | 24 | return () => { 25 | layers.mapnik.addTo(map); 26 | map.off('click', mapClick); 27 | }; 28 | } 29 | }, [map, mapClick]); 30 | 31 | return <> 32 | } 33 | -------------------------------------------------------------------------------- /src/services/JOSMRemote.ts: -------------------------------------------------------------------------------- 1 | import { OSMElement } from "./OSMData.types"; 2 | 3 | const BASE = 'http://localhost:8111' 4 | 5 | /** 6 | * Interface to call JOSM Remote commands 7 | * https://josm.openstreetmap.de/wiki/Help/RemoteControlCommands 8 | */ 9 | 10 | /** 11 | * https://josm.openstreetmap.de/wiki/Help/RemoteControlCommands#load_object 12 | */ 13 | export function loadInJOSM(osmElements: OSMElement[], loadReferers = false, newLayer = true, members = false) { 14 | const ids = osmElements?.map(e => `${e.type[0]}${e.id}`); 15 | 16 | const urlArgs = { 17 | objects: ids.join(), 18 | new_layer: newLayer, 19 | referrers: loadReferers, 20 | relation_members: members 21 | } 22 | 23 | const url = new URL('load_object', BASE); 24 | Object.entries(urlArgs).forEach(([k, v]) => url.searchParams.append(k, v.toString())); 25 | 26 | console.log('Call josm remote', url); 27 | fetch(url).catch(e => { 28 | alert('failed to open JOSM remote'); 29 | console.warn('Failed to call JOSM remote', e); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/models/BBOX.ts: -------------------------------------------------------------------------------- 1 | export default class BBOX { 2 | minx: number 3 | miny: number 4 | maxx: number 5 | maxy: number 6 | 7 | constructor(minx: number, miny: number, maxx: number, maxy: number) { 8 | this.minx = minx; 9 | this.maxx = maxx ?? minx; 10 | 11 | this.miny = miny; 12 | this.maxy = maxy ?? miny; 13 | } 14 | 15 | extend(x: number, y: number) { 16 | this.minx = Math.min(this.minx, x); 17 | this.maxx = Math.max(this.maxx, x); 18 | 19 | this.miny = Math.min(this.miny, y); 20 | this.maxy = Math.max(this.maxy, y); 21 | } 22 | 23 | getCenter() { 24 | return { 25 | x: (this.maxx + this.miny) / 2.0, 26 | y: (this.maxy + this.miny) / 2.0, 27 | }; 28 | } 29 | } 30 | 31 | export function expandBBOX(bbox: BBOX, delta: number) { 32 | const minx = bbox.minx - delta; 33 | const miny = bbox.miny - delta; 34 | 35 | const maxx = bbox.maxx + delta; 36 | const maxy = bbox.maxy + delta; 37 | 38 | return new BBOX(minx, miny, maxx, maxy); 39 | } -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Dmitry Kiselev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .main-container { 2 | display: flex; 3 | flex-direction: row; 4 | width: 100%; 5 | height: 100%; 6 | } 7 | .main-right { 8 | flex: 1 0 auto; 9 | width: 100px; 10 | height: 100%; 11 | } 12 | .main-left { 13 | width: 30%; 14 | flex: 0 0 auto; 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | .main-divider { 19 | width: 0.2em; 20 | background-color: #666; 21 | flex: 0 0 auto; 22 | /* cursor: col-resize; */ 23 | } 24 | 25 | 26 | .main-left .tab { 27 | height: min-content; 28 | flex: 1 0 auto; 29 | } 30 | 31 | .tab-header { 32 | margin-top: 0.5em; 33 | margin-bottom: 1em; 34 | padding-left: 0.5em; 35 | } 36 | .tab-header span { 37 | padding: 0.25em 0.5em 0.25em 0.5em; 38 | } 39 | .tab-selector:hover { 40 | cursor: pointer; 41 | } 42 | .tab-selector.active { 43 | background-color: aquamarine; 44 | } 45 | .tab { 46 | display: none; 47 | } 48 | .tab.active { 49 | height: min-content; 50 | display: flex; 51 | flex-direction: column; 52 | } 53 | .scroll-pane { 54 | height: 25em; 55 | flex: 1 0 auto; 56 | overflow-y: auto; 57 | } 58 | .tab > div { 59 | padding-left: 0.5em; 60 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osm-gtfs", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", 10 | "preview": "vite preview", 11 | "deploy": "tsc && vite build --base \"/osm-gtfs/\" && gh-pages -d dist" 12 | }, 13 | "dependencies": { 14 | "classnames": "^2.3.2", 15 | "jszip": "^3.10.1", 16 | "kdbush": "^4.0.2", 17 | "leaflet": "^1.9.4", 18 | "papaparse": "^5.4.1", 19 | "react": "^18.2.0", 20 | "react-dom": "^18.2.0", 21 | "react-dropzone": "^14.2.3", 22 | "xml": "^1.0.1" 23 | }, 24 | "devDependencies": { 25 | "@types/leaflet": "^1.9.8", 26 | "@types/papaparse": "^5.3.14", 27 | "@types/react": "^18.2.64", 28 | "@types/react-dom": "^18.2.21", 29 | "@types/xml": "^1.0.11", 30 | "@typescript-eslint/eslint-plugin": "^7.1.1", 31 | "@typescript-eslint/parser": "^7.1.1", 32 | "@vitejs/plugin-react": "^4.2.1", 33 | "eslint": "^8.57.0", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "eslint-plugin-react-refresh": "^0.4.5", 36 | "gh-pages": "^6.1.1", 37 | "typescript": "^5.2.2", 38 | "vite": "^5.1.6", 39 | "vite-plugin-node-polyfills": "^0.21.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/components/RematchController.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import { EditSubjectType, EditorActionData } from "../models/Editor"; 3 | import { StopMatchData } from "../services/Matcher"; 4 | import { StopMatch } from "../services/Matcher.types"; 5 | import MapMatchMarker from "./MapMatchMarker"; 6 | 7 | export type onSelectCB = (selection?: StopMatch) => void; 8 | 9 | export type RematchControllerProps = { 10 | editSubj: EditSubjectType 11 | doneEdit: (data: EditorActionData) => void 12 | matchData: StopMatchData 13 | }; 14 | export default function RematchController({editSubj, doneEdit, matchData}: RematchControllerProps) { 15 | 16 | const osmOrphants = matchData.unmatchedOsm; 17 | 18 | const onSelect = useCallback(selection => { 19 | doneEdit({ 20 | newMatch: selection 21 | }); 22 | }, [editSubj, doneEdit]); 23 | 24 | const markers = osmOrphants.map(match => 25 | 30 | ); 31 | 32 | return <> 33 | 38 | { markers} 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/components/MapTrip.tsx: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import { useContext, useEffect } from 'react'; 3 | 4 | import { StopMatchesSequence } from '../models/StopMatchesSequence'; 5 | import { MapContext } from './Map'; 6 | import './maptrip.css'; 7 | 8 | 9 | type MapTripProps = { 10 | matchTrip: StopMatchesSequence 11 | }; 12 | export default function MapTrip({matchTrip}: MapTripProps) { 13 | 14 | const map = useContext(MapContext); 15 | 16 | useEffect(() => { 17 | if (matchTrip && !matchTrip.stopMatchSequence) { 18 | console.warn('Strange matchTrip', matchTrip); 19 | return; 20 | } 21 | 22 | console.log('Redraw trip'); 23 | if (map && matchTrip) { 24 | 25 | const latlngs = matchTrip.stopMatchSequence.map(match => { 26 | const { gtfsStop, osmStop } = match; 27 | const stop = osmStop || gtfsStop; 28 | return stop ? stop.getLonLat() : undefined 29 | }) 30 | .filter(ll => ll !== undefined) 31 | .map(ll => ({lat: ll!.lat, lng: ll!.lon})); 32 | 33 | const pline = L.polyline(latlngs, { 34 | className: 'trip-path' 35 | }); 36 | 37 | pline.addTo(map); 38 | return () => { 39 | pline.remove(); 40 | }; 41 | } 42 | }, [map, matchTrip]); 43 | 44 | return <> 45 | } -------------------------------------------------------------------------------- /src/components/QueryOSM.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import { expandBBOX } from '../models/BBOX'; 4 | import GTFSData from '../models/GTFSData'; 5 | import OSMData, { queryRoutes, queryStops } from '../services/OSMData'; 6 | 7 | 8 | export type QeryOSMProps = { 9 | gtfsData?: GTFSData 10 | osmData?: OSMData 11 | setOSMData: (osmData: OSMData) => void 12 | }; 13 | 14 | // TODO: remove osmData from props and use singleton, 15 | // or use osmData provided via props instead of singleton 16 | // basically just need to check that 17 | // `const _osmData = osmData || OSMData.getInstance();` works 18 | 19 | // @ts-ignore osmData is never read localy 20 | export default function QeryOSM({gtfsData, osmData, setOSMData}: QeryOSMProps) { 21 | 22 | const [qeryInProgress, setQueryInProgress] = useState(false); 23 | 24 | const queryOSMCallback = useCallback(async () => { 25 | if(gtfsData && gtfsData.bbox) { 26 | setQueryInProgress(true); 27 | 28 | const osmData = OSMData.getInstance(); 29 | const bbox = expandBBOX(gtfsData.bbox, 0.001); 30 | try { 31 | osmData.updateOverpassData(await queryStops(bbox)); 32 | osmData.updateOverpassData(await queryRoutes(bbox)); 33 | } 34 | finally { 35 | setOSMData(osmData); 36 | setQueryInProgress(false); 37 | } 38 | } 39 | else { 40 | console.warn("Can't query OSM gtfsData or gtfsData.bbox is empty"); 41 | } 42 | }, [gtfsData, setQueryInProgress]); 43 | 44 | return <> 45 | { gtfsData && } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/help/example.osm: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/components/RouteTripsEditor.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react" 2 | import { GTFSTripUnion } from "../models/GTFSData" 3 | import { OsmRoute } from "../models/OsmRoute" 4 | import { RouteMatchType } from "../services/Matcher.types" 5 | import { OSMRelation } from "../services/OSMData.types" 6 | import { getGTFSRouteName } from "./RouteMatch" 7 | 8 | export type RouteTripsEditorContext = { 9 | routeMatch?: RouteMatchType 10 | gtfsTrip?: GTFSTripUnion 11 | osmTrip?: OsmRoute 12 | } 13 | 14 | 15 | export type IRouteTripsEditorState = { 16 | routeEditorSubj: RouteTripsEditorContext 17 | setRouteEditorSubj: (subj: RouteTripsEditorContext) => any 18 | } 19 | 20 | export type RouteTripsEditorProps = { 21 | } & IRouteTripsEditorState 22 | 23 | export default function RouteTripsEditor({ 24 | routeEditorSubj}: RouteTripsEditorProps) { 25 | 26 | const { routeMatch } = routeEditorSubj; 27 | 28 | const [osmTripHighlight, setOsmTripHighlight] = useState(); 29 | 30 | const name = getGTFSRouteName(routeMatch?.gtfsRoute); 31 | 32 | const tripRelations = routeMatch?.osmRoute?.tripRelations; 33 | 34 | const osmTripOptions = tripRelations?.map(rel => { 35 | return
{rel.tags.name}
36 | }); 37 | 38 | const tags = osmTripHighlight && Object.entries(osmTripHighlight.tags).map(([k, v]) => { 39 | return
{k} = {v}
40 | }); 41 | 42 | return <> 43 |
44 | {name} 45 |
46 |
OSM Route: {routeMatch?.osmRoute?.name}
47 | {tags} 48 |
49 |
50 |
51 | { osmTripOptions } 52 |
53 | 54 | } -------------------------------------------------------------------------------- /src/models/OsmStop.ts: -------------------------------------------------------------------------------- 1 | import { getElementLonLat } from "../services/OSMData"; 2 | import { LonLat, OSMElement, OSMRelation } from "../services/OSMData.types"; 3 | import { lonLatToMerc } from "./MercatorUtil"; 4 | 5 | export default class OsmStop { 6 | stopPosition?: OSMElement 7 | platform?: OSMElement 8 | relation: OSMRelation | null 9 | 10 | constructor(stopPosition?: OSMElement, platform?: OSMElement) { 11 | this.platform = platform; 12 | this.stopPosition = stopPosition; 13 | 14 | this.relation = null; 15 | } 16 | 17 | getName() { 18 | const e = this.stopPosition || this.platform; 19 | return e?.tags?.name; 20 | } 21 | 22 | getId() { 23 | const e = this.stopPosition || this.platform; 24 | return `${e?.type}${e?.id}`; 25 | } 26 | 27 | getPosition3857() { 28 | try { 29 | const e = this.stopPosition || this.platform; 30 | if (e) { 31 | // @ts-ignore 32 | const [lon, lat] = getElementLonLat(e); 33 | return lonLatToMerc(lon, lat); 34 | } 35 | } 36 | catch (err) { 37 | console.log(this); 38 | console.error('Failed to get OSM element position', err); 39 | } 40 | } 41 | 42 | getLonLat() { 43 | try { 44 | const e = this.stopPosition || this.platform; 45 | if (e) { 46 | const llTuple = getElementLonLat(e); 47 | if (llTuple) { 48 | const [lon, lat] = llTuple; 49 | return {lon, lat} as LonLat; 50 | } 51 | } 52 | } 53 | catch (err) { 54 | console.log(this); 55 | console.error('Failed to get OSM element lon lat', err); 56 | } 57 | } 58 | 59 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import node_resolve from '@rollup/plugin-node-resolve'; 2 | import babel from '@rollup/plugin-babel'; 3 | import hotcss from 'rollup-plugin-hot-css'; 4 | import commonjs from 'rollup-plugin-commonjs-alternate'; 5 | import static_files from 'rollup-plugin-static-files'; 6 | import { terser } from 'rollup-plugin-terser'; 7 | import refresh from 'rollup-plugin-react-refresh'; 8 | 9 | import nodePolyfills from 'rollup-plugin-polyfill-node'; 10 | 11 | import copy from 'rollup-plugin-copy'; 12 | import markdown from '@jackfranklin/rollup-plugin-markdown'; 13 | 14 | let config = { 15 | input: './src/main.js', 16 | output: { 17 | dir: 'dist', 18 | format: 'esm', 19 | entryFileNames: '[name].[hash].js', 20 | assetFileNames: '[name].[hash][extname]' 21 | }, 22 | watch: { 23 | exclude: ['public/build/**', 'public/images/**'] 24 | }, 25 | plugins: [ 26 | hotcss({ 27 | hot: process.env.NODE_ENV === 'development', 28 | filename: 'styles.css' 29 | }), 30 | babel({ 31 | exclude: 'node_modules/**', 32 | presets: ["@babel/preset-react"] 33 | }), 34 | nodePolyfills(), 35 | node_resolve(), 36 | commonjs({ 37 | define: { 38 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) 39 | } 40 | }), 41 | process.env.NODE_ENV === 'development' && refresh(), 42 | copy({ 43 | targets: [ 44 | { src: 'node_modules/leaflet/dist/images/*', dest: 'public/images' } 45 | ] 46 | }), 47 | markdown({ 48 | exclude: 'README.md', 49 | }) 50 | ] 51 | } 52 | 53 | if (process.env.NODE_ENV === 'production') { 54 | config.plugins = config.plugins.concat([ 55 | static_files({ 56 | include: ['./public'] 57 | }), 58 | terser() 59 | ]); 60 | } 61 | 62 | export default config; -------------------------------------------------------------------------------- /src/components/Changes.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from "react"; 2 | import xml from "xml"; 3 | 4 | import OSMData, { OSMDataChange } from "../services/OSMData"; 5 | import "./Changes.css"; 6 | 7 | type ChangesProps = { 8 | osmData?: OSMData 9 | }; 10 | export function Changes({osmData}: ChangesProps) { 11 | const downloadHandler = useCallback(() => { 12 | if (osmData && osmData.changes) { 13 | const data = encodeChanges(osmData.changes); 14 | 15 | const blob = new Blob([data], { type: 'application/xml' }); 16 | const url = URL.createObjectURL(blob); 17 | 18 | download(url, 'gtfs-changes.osm'); 19 | 20 | URL.revokeObjectURL(url); 21 | } 22 | }, [osmData]); 23 | 24 | return <> 25 |

Changes

26 | {osmData && osmData.changes.map(ch =>
27 | {ch.element.type} 28 | {ch.element.id} 29 | {asArray(ch.action).join(', ')} 30 |
)} 31 | 32 | 33 | } 34 | 35 | function encodeChanges(changes: OSMDataChange[]) { 36 | const xmlNodes = changes.map(({element}) => { 37 | const tagElements = Object.entries(element.tags) 38 | .map(([k, v]) => ({tag: {_attr: {k, v}}})); 39 | 40 | const {type, tags, ...attr} = element; 41 | 42 | // @ts-ignore 43 | attr['action'] = 'modify'; 44 | 45 | return { 46 | [element.type]: [{_attr: attr}, ...tagElements] 47 | } 48 | }); 49 | 50 | return xml({osm: [{_attr: {version: "0.6", generator: "osm-gtfs"}}, ...xmlNodes]}, { declaration: true }); 51 | } 52 | 53 | function asArray(arg: any) { 54 | return Array.isArray(arg) ? arg : [arg]; 55 | } 56 | 57 | function download(path: string, filename: string) { 58 | const anchor = document.createElement('a'); 59 | anchor.href = path; 60 | anchor.download = filename; 61 | 62 | document.body.appendChild(anchor); 63 | 64 | anchor.click(); 65 | 66 | document.body.removeChild(anchor); 67 | } 68 | -------------------------------------------------------------------------------- /src/components/MapMatchMarker.tsx: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import React, { useContext, useEffect, useRef } from 'react'; 3 | 4 | import { StopMatch } from '../services/Matcher.types'; 5 | import { MapContext } from './Map'; 6 | 7 | type MapMatchMarkerProps = { 8 | match: StopMatch 9 | selectedMatch?: StopMatch 10 | selectMatch?: (match?: StopMatch) => void 11 | }; 12 | const MapMatchMarker: React.FC = ({ match, selectedMatch, selectMatch }) => { 13 | 14 | const map = useContext(MapContext); 15 | const markerRef = useRef(); 16 | 17 | const gtfsStop = match.gtfsStop; 18 | const osmStop = match.osmStop; 19 | 20 | const lonLat = osmStop?.getLonLat() || gtfsStop; 21 | 22 | const color = getMatchColor(match); 23 | 24 | useEffect(() => { 25 | if (map && match && lonLat) { 26 | const {lon, lat} = lonLat; 27 | 28 | const marker = L.circle({lng: lon, lat}, 3, {color}); 29 | markerRef.current = marker; 30 | 31 | marker.on('click', () => { selectMatch && selectMatch(match); }); 32 | 33 | marker.addTo(map); 34 | 35 | return () => { 36 | marker.removeFrom(map); 37 | } 38 | } 39 | }, [map, lonLat, color, selectMatch, match]); 40 | 41 | useEffect(() => { 42 | if (match && selectedMatch && match === selectedMatch) { 43 | const marker = markerRef.current; 44 | if (marker) { 45 | marker.setRadius(6); 46 | 47 | return () => { 48 | marker.setRadius(3); 49 | }; 50 | } 51 | } 52 | }, [map, match, selectedMatch]); 53 | 54 | return <>; 55 | } 56 | 57 | export default MapMatchMarker; 58 | 59 | 60 | export function getMatchColor(match: StopMatch) { 61 | const gtfsStop = match.gtfsStop; 62 | const osmStop = match.osmStop; 63 | 64 | if (osmStop && gtfsStop) { 65 | return '#3399FF'; 66 | } 67 | 68 | // unmatched GTFS 69 | if (gtfsStop && !osmStop) { 70 | return gtfsStop.code ? '#ff0000' : '#FF8080'; 71 | } 72 | 73 | // unmatched OSM 74 | if (osmStop && !gtfsStop) { 75 | return '#000000' 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/HelpMarkdown.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | 3 | import './help.css'; 4 | 5 | export function HelpRefCodeTag() { 6 | const [folded, setFolded] = useState(true); 7 | 8 | const toggleCallback = useCallback(() => { 9 | setFolded(!folded); 10 | }, [folded, setFolded]); 11 | 12 | return
13 | {folded ? 'show help' : 'hide'} 14 | { !folded &&
15 | First step is to match stops from GTFS with stops in OSM. 16 | 17 | I count stop matched if OSM version has GTFS stop 18 | code or id in it's tags. There is no particular 19 | tag which would fit in all situations, 20 | so this tag is configurable. 21 | 22 | Some examples: 23 |
    24 |
  • ref
  • 25 |
  • gtfs:ref
  • 26 |
  • <operator_name>:ref
  • 27 |
28 | 29 | After OSM data query is loaded, you can 30 | find OSM tags statisc for tags containing 31 | ref or gtfs above. 32 |
} 33 |
34 | 35 | } 36 | 37 | export function HelpNameTemplate() { 38 | const [folded, setFolded] = useState(true); 39 | 40 | const toggleCallback = useCallback(() => { 41 | setFolded(!folded); 42 | }, [folded, setFolded]); 43 | 44 | return
45 | {folded ? 'show help' : 'hide'} 46 | { !folded &&
47 | For matched stops, it might be usefull to edit 48 | names accordingly to a common pattern. 49 | 50 | You can use a template to edit stop names: 51 | 52 | Use $name, $code, $id, $description 53 | to substitute corresponding GTFS values. 54 | 55 | You can apply regexp replace patterns to substituted values: 56 | $name.re('<search_expression>', '<replace_expression>') 57 | 58 | For instance if you want to remove text in brackets from 59 | $name use $name.re('(.*)', '') 60 |
} 61 | 62 |
63 | 64 | } -------------------------------------------------------------------------------- /src/components/GTFSLoad.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useState } from 'react'; 2 | import { useDropzone } from 'react-dropzone'; 3 | import GTFSData from '../models/GTFSData'; 4 | 5 | import './gtfsload.css'; 6 | 7 | // @ts-ignore 8 | import JSZip from 'jszip/dist/jszip.min.js'; 9 | 10 | type DropzoneProps = { 11 | onLoad: (file: File) => void; 12 | }; 13 | function Dropzone({onLoad}: DropzoneProps) { 14 | // @ts-ignore 15 | const onDrop = useCallback(acceptedFiles => { 16 | onLoad( acceptedFiles[0] ); 17 | }, [onLoad]); 18 | 19 | const cfg = { 20 | onDrop, 21 | accept: { 22 | 'application/zip': ['.zip'] 23 | } 24 | }; 25 | 26 | const {getRootProps, getInputProps, isDragActive} = useDropzone(cfg); 27 | 28 | return ( 29 |
30 | 31 | { 32 | isDragActive ? 33 |

Drop the files here ...

: 34 |

Drag 'n' drop GTFS zip file ( or click to select )

35 | } 36 |
37 | ) 38 | } 39 | 40 | type fileCB = (f: File) => void 41 | 42 | type GTFSLoadProps = { 43 | gtfsData?: GTFSData 44 | onDataLoaded: (gtfsData: GTFSData) => void 45 | }; 46 | export default function GTFSLoad ({gtfsData, onDataLoaded}: GTFSLoadProps) { 47 | 48 | const [file, setFile] = useState(); 49 | 50 | const processZipFile = useCallback(async f => { 51 | setFile(f); 52 | 53 | const gtfsData = new GTFSData(); 54 | 55 | const zipData = await new JSZip().loadAsync(f); 56 | const stopsZip = zipData.files['stops.txt']; 57 | 58 | if (stopsZip) { 59 | const stopsCSV = await stopsZip.async('string'); 60 | gtfsData.loadStops(stopsCSV); 61 | } 62 | 63 | const routes = await zipData.files['routes.txt']?.async('string'); 64 | const trips = await zipData.files['trips.txt']?.async('string'); 65 | const stopTimes = await zipData.files['stop_times.txt']?.async('string'); 66 | 67 | if (routes && trips && stopTimes) { 68 | gtfsData.loadRoutes(routes, trips, stopTimes); 69 | } 70 | 71 | onDataLoaded(gtfsData); 72 | }, [onDataLoaded]); 73 | 74 | return <> 75 | { 76 | // @ts-ignore 77 | file ?
{file.name}
: 78 | } 79 | { gtfsData &&
Loaded {gtfsData.stops.length} stops
} 80 | 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/components/OpenCurentViewInJosm.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useRef } from "react"; 2 | 3 | import L from "leaflet"; 4 | 5 | import { loadInJOSM } from "../services/JOSMRemote"; 6 | import { StopMatch } from "../services/Matcher.types"; 7 | import { getElementLonLat, lonLatToLatLng } from "../services/OSMData"; 8 | import { OSMElement } from "../services/OSMData.types"; 9 | import { MapContext } from "./Map"; 10 | 11 | const OpenInJOSMControl = L.Control.extend({ 12 | onAdd: function() { 13 | var button = L.DomUtil.create('button'); 14 | button.innerText = 'Open in JOSM' 15 | button.onclick = this.onClick; 16 | return button; 17 | }, 18 | 19 | onRemove: function() { 20 | }, 21 | 22 | onClick: function() { 23 | console.warn('Empty onclick handler'); 24 | } 25 | }); 26 | 27 | const controlInstance = new OpenInJOSMControl({position: 'topright'}); 28 | 29 | export type OpenCurentViewInJosmProps = { 30 | filteredMatches?: StopMatch[] 31 | }; 32 | export default function OpenCurentViewInJosm({filteredMatches}: OpenCurentViewInJosmProps) { 33 | 34 | const map = useContext(MapContext); 35 | const matchesRef = useRef(); 36 | matchesRef.current = filteredMatches; 37 | 38 | controlInstance.onClick = () => { 39 | const matches = matchesRef.current; 40 | if (matches && map) { 41 | const bounds = map.getBounds(); 42 | const osmElements: OSMElement[] = []; 43 | 44 | matches.forEach(({osmStop}) => { 45 | if (osmStop) { 46 | const {stopPosition, platform} = osmStop; 47 | 48 | // @ts-ignore 49 | const spLatLng = stopPosition && lonLatToLatLng(getElementLonLat(stopPosition)); 50 | // @ts-ignore 51 | const plLatLng = platform && lonLatToLatLng(getElementLonLat(platform)); 52 | 53 | if (stopPosition && spLatLng && bounds.contains(spLatLng)) { 54 | osmElements.push(stopPosition); 55 | } 56 | 57 | if (platform && plLatLng && bounds.contains(plLatLng)) { 58 | osmElements.push(platform); 59 | } 60 | } 61 | }); 62 | 63 | loadInJOSM(osmElements, true); 64 | } 65 | }; 66 | 67 | useEffect(() => { 68 | if (map) { 69 | map.addControl(controlInstance); 70 | return () => { 71 | map.removeControl(controlInstance); 72 | } 73 | } 74 | }, [map]) 75 | 76 | return <> 77 | } -------------------------------------------------------------------------------- /src/components/MatchEditor.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useCallback } from 'react'; 3 | import { CREATE_NEW, EditSubjectType, EditorRoleEnum, SET_MATCH, SET_POSITION } from '../models/Editor'; 4 | import { StopMatch } from '../services/Matcher.types'; 5 | 6 | export type MatchEditorProps = { 7 | match: StopMatch 8 | elementRole: EditorRoleEnum 9 | editSubj?: EditSubjectType 10 | setEditSubj?: (subj?: EditSubjectType) => void 11 | }; 12 | 13 | export default function MatchEditor({ 14 | match, elementRole, 15 | editSubj, setEditSubj, 16 | }: MatchEditorProps) { 17 | 18 | const reassignButtonHandler = useCallback(() => { 19 | setEditSubj && setEditSubj({ 20 | match, 21 | action: SET_MATCH, 22 | role: elementRole 23 | }); 24 | }, [match, elementRole, setEditSubj]); 25 | 26 | const createButtonHandler = useCallback(() => { 27 | setEditSubj && setEditSubj({ 28 | match, 29 | action: CREATE_NEW, 30 | role: elementRole 31 | }); 32 | }, [match, elementRole, setEditSubj]); 33 | 34 | const moveButtonHandler = useCallback(() => { 35 | setEditSubj && setEditSubj({ 36 | match, 37 | action: SET_POSITION, 38 | role: elementRole 39 | }); 40 | }, [match, elementRole, setEditSubj]); 41 | 42 | const cancelEditHandler = useCallback(() => { 43 | setEditSubj && setEditSubj(undefined); 44 | }, [setEditSubj]); 45 | 46 | const osmStop = match.osmStop; 47 | const gtfsStop = match.gtfsStop; 48 | 49 | const osmElement = osmStop?.[elementRole]; 50 | const orphantOsm = osmStop && !gtfsStop; 51 | 52 | const editIsActive = editSubj && editSubj?.match === match; 53 | return ( 54 |
55 | { editIsActive && 56 | } 59 | 60 | { !osmStop && 61 | } 65 | 66 | { osmElement && 67 | } 72 | 73 | {!orphantOsm && 74 | } 80 | 81 | {orphantOsm && 82 | } 85 |
86 | ); 87 | 88 | } -------------------------------------------------------------------------------- /src/components/MatchDetailsTripList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import IconCheckBox from './IconCheckBox'; 3 | 4 | import GTFSData, { GTFSStop, GTFSTripUnion } from '../models/GTFSData'; 5 | import './match-details-trip-list.css'; 6 | 7 | 8 | type tripSelectionHandlerCB = (gtfsTrip: GTFSTripUnion, selected: boolean) => void; 9 | type selectionCB = (selected?: boolean) => void; 10 | 11 | export type MatchDetailsTripListProps = { 12 | gtfsStop: GTFSStop 13 | gtfsData: GTFSData 14 | highlightedTrip?: GTFSTripUnion 15 | setHighlightedTrip?: (trip?: GTFSTripUnion) => void 16 | }; 17 | 18 | export default function MatchDetailsTripList({ 19 | gtfsStop, 20 | gtfsData, 21 | highlightedTrip, 22 | setHighlightedTrip 23 | }: MatchDetailsTripListProps) { 24 | 25 | const tripSelectionHandler = useCallback((gtfsTrip, selected) => { 26 | setHighlightedTrip && setHighlightedTrip(selected ? gtfsTrip : undefined); 27 | }, [setHighlightedTrip]); 28 | 29 | const trips = gtfsData.stopToTrips[gtfsStop?.id]; 30 | 31 | const tripList = trips?.map((trip, i) => 32 | 37 | ); 38 | 39 | return gtfsStop ? ( 40 | <> 41 |

Routes on this stop

42 |
43 | { tripList } 44 |
45 | 46 | ) : <>; 47 | } 48 | 49 | export type MatchDetailsTripListElementProps = { 50 | gtfsTrip: GTFSTripUnion 51 | selected: boolean 52 | gtfsData: GTFSData 53 | onSelectionChange: tripSelectionHandlerCB 54 | }; 55 | 56 | function MatchDetailsTripListElement({ 57 | gtfsTrip, selected, gtfsData, onSelectionChange 58 | }: MatchDetailsTripListElementProps) { 59 | 60 | const handleCheckboxChange = useCallback(checked => { 61 | onSelectionChange && onSelectionChange(gtfsTrip, !!checked); 62 | }, [gtfsTrip, onSelectionChange]); 63 | 64 | const route = gtfsData.routes[gtfsTrip.routeId]; 65 | 66 | return (
67 | 70 | 75 | {gtfsTrip.headSign && 76 | { gtfsTrip.headSign } 77 | } 78 | {route && 79 | { `(${route.shortName} - ${route.longName})` } 80 | } 81 |
); 82 | } -------------------------------------------------------------------------------- /src/components/MatchSettings.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { MatchSettingsType } from '../services/Matcher.types'; 3 | import { HelpRefCodeTag } from './HelpMarkdown'; 4 | import "./MatchSettings.css"; 5 | 6 | type boolCB = (v: boolean) => void; 7 | type strCB = (v: string) => void; 8 | 9 | export type MatchSettingsProps = { 10 | matchSettings: MatchSettingsType 11 | setMatchSettings: (settings: MatchSettingsType) => void 12 | }; 13 | export default function MatchSettings({ matchSettings, setMatchSettings }: MatchSettingsProps) { 14 | const { refTag, matchByName, matchByCodeInName } = matchSettings; 15 | 16 | const setTag: strCB = value => { 17 | setMatchSettings({ 18 | ...matchSettings, 19 | refTag: value, 20 | }); 21 | }; 22 | 23 | const setMatchByName: boolCB = value => { 24 | setMatchSettings({ 25 | ...matchSettings, 26 | matchByName: value 27 | }); 28 | } 29 | 30 | const setMatchByCodeInName: boolCB = value => { 31 | setMatchSettings({ 32 | ...matchSettings, 33 | matchByCodeInName: value 34 | }); 35 | } 36 | 37 | // const setNameTemplate = value => { 38 | // setMatchSettings({ 39 | // ...matchSettings, 40 | // nameTemplate: value, 41 | // }); 42 | // }; 43 | 44 | return
45 |
46 | OSM Tag with GTFS stop code: 47 | 48 | setTag(e.target.value)}> 49 | 50 |
51 | 52 |
53 | Match stops by name: 54 | 55 | setMatchByName(e.target.checked)}> 57 | 58 |
59 |
60 | Match stops by GTFS code in name: 61 | 62 | setMatchByCodeInName(e.target.checked)}> 64 | 65 |
66 | 67 | {/* 68 |
69 | Name template: 70 | 71 | setNameTemplate(e.target.value)}> 72 | 73 |
*/} 74 | 75 |
76 | } 77 | 78 | export function matchSettingsMatch(settingsA?: MatchSettingsType, settingsB?: MatchSettingsType) { 79 | 80 | if (settingsA && settingsB) { 81 | return settingsA.refTag === settingsB.refTag && 82 | settingsA.matchByName === settingsB.matchByName && 83 | settingsA.matchByCodeInName === settingsB.matchByCodeInName 84 | } 85 | 86 | return settingsA === settingsB; 87 | } -------------------------------------------------------------------------------- /src/models/Filters.ts: -------------------------------------------------------------------------------- 1 | import { StopMatchData } from "../services/Matcher"; 2 | import { OSMRelation } from "../services/OSMData.types"; 3 | import { GTFSRoute, GTFSTripUnion } from "./GTFSData"; 4 | import { OsmRoute } from "./OsmRoute"; 5 | 6 | export type RouteOrTrip = GTFSRoute | GTFSTripUnion | OsmRoute | OSMRelation; 7 | 8 | export type ListFiltersType = { 9 | showMatched: boolean 10 | showUnmatchedGtfs: boolean 11 | showUnmatchedOsm: boolean 12 | filterBy: RouteOrTrip[] 13 | }; 14 | 15 | export const defaultFilters = { 16 | showMatched: true, 17 | showUnmatchedGtfs: true, 18 | showUnmatchedOsm: true, 19 | filterBy: [] 20 | }; 21 | 22 | export function filterMatches(matchData: StopMatchData, filters: ListFiltersType) { 23 | const { 24 | showMatched, 25 | showUnmatchedGtfs, 26 | showUnmatchedOsm, 27 | 28 | filterBy 29 | } = filters; 30 | 31 | if (filterBy && filterBy.length > 0) { 32 | const tripStopsIds = new Set(); 33 | const filterByTrips: GTFSTripUnion[] = []; 34 | const filterByOsmTrips: OSMRelation[] = []; 35 | 36 | filterBy.forEach(filter => { 37 | if (filter instanceof GTFSRoute) { 38 | filterByTrips.push(...filter.trips); 39 | } 40 | else if (filter instanceof GTFSTripUnion) { 41 | filterByTrips.push(filter); 42 | } 43 | }); 44 | 45 | filterBy.forEach(filter => { 46 | if (filter instanceof OsmRoute) { 47 | filterByOsmTrips.push(...filter.tripRelations); 48 | } 49 | else if ((filter as OSMRelation).type === 'relation') { 50 | filterByOsmTrips.push(filter as OSMRelation); 51 | } 52 | }); 53 | 54 | filterByTrips.forEach(trip => { 55 | trip.stopSequence.forEach(stop => { 56 | tripStopsIds.add(stop.id); 57 | }); 58 | }); 59 | 60 | // At this stage we filtered stops by GTFS trips and routes 61 | const filtered = [...matchData.matched, ...matchData.unmatchedGtfs] 62 | .filter(match => tripStopsIds.has(match.gtfsStop?.id)); 63 | 64 | // Add stops filtered by OSM trips or routes 65 | filterByOsmTrips.forEach(osmTrip => { 66 | 67 | const platforms = osmTrip.members 68 | .filter(m => m.role === 'platform' || m.role === 'stop_position' || m.type === 'node'); 69 | 70 | platforms.forEach(member => { 71 | const match = matchData?.findMatchByOsmElementTypeAndId(member.type, member.ref); 72 | 73 | // TODO: filtered.includes is probabbly inneficient 74 | if (match && !filtered.includes(match)) { 75 | filtered.push(match); 76 | } 77 | }); 78 | }); 79 | 80 | return filtered; 81 | } 82 | 83 | const results = []; 84 | 85 | showMatched && results.push(...matchData.matched); 86 | showUnmatchedGtfs && results.push(...matchData.unmatchedGtfs); 87 | showUnmatchedOsm && results.push(...matchData.unmatchedOsm); 88 | 89 | return results; 90 | } -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/MatchDetails.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useCallback } from 'react'; 3 | import MatchDetailsTripList from './MatchDetailsTripList'; 4 | import MatchEditor from './MatchEditor'; 5 | import OSMElementTagsEditor from './OsmTags'; 6 | 7 | import { EditSubjectType } from '../models/Editor'; 8 | import GTFSData, { GTFSTripUnion } from '../models/GTFSData'; 9 | import { StopMatch } from '../services/Matcher.types'; 10 | import OSMData from '../services/OSMData'; 11 | import "./MatchDetails.css"; 12 | 13 | export type MatchDetailsProps = { 14 | match: StopMatch 15 | osmData: OSMData 16 | gtfsData: GTFSData 17 | 18 | highlightedTrip?: GTFSTripUnion 19 | setHighlightedTrip?: (trip?: GTFSTripUnion) => void 20 | 21 | editSubj?: EditSubjectType 22 | setEditSubj?: (editSubj?: EditSubjectType) => void 23 | 24 | handleSelectNextInTrip?: (match: StopMatch, trip: GTFSTripUnion) => void 25 | handleSelectPrevInTrip?: (match: StopMatch, trip: GTFSTripUnion) => void 26 | }; 27 | export default function MatchDetails({ 28 | match, osmData, gtfsData, 29 | highlightedTrip, setHighlightedTrip, 30 | editSubj, setEditSubj, 31 | handleSelectNextInTrip, handleSelectPrevInTrip 32 | }: MatchDetailsProps) { 33 | 34 | const {osmStop, gtfsStop} = match; 35 | 36 | const platform = osmStop?.platform; 37 | 38 | // TODO: Properly account for a case when 39 | // there are both stop position and platform 40 | // or just stop position 41 | 42 | // @ts-ignore 43 | const stopPosition = osmStop?.stopPosition; 44 | 45 | const name = osmStop?.getName() || gtfsStop?.name; 46 | const gtfsCode = gtfsStop?.code; 47 | 48 | const nextInTripHandler = useCallback(() => { 49 | if (match && highlightedTrip && handleSelectNextInTrip) { 50 | handleSelectNextInTrip(match, highlightedTrip); 51 | } 52 | }, [match, highlightedTrip, handleSelectNextInTrip]); 53 | 54 | const prevInTripHandler = useCallback(() => { 55 | if (match && highlightedTrip && handleSelectPrevInTrip) { 56 | handleSelectPrevInTrip(match, highlightedTrip); 57 | } 58 | }, [match, highlightedTrip, handleSelectPrevInTrip]); 59 | 60 | return
61 |

{ name }

62 | { gtfsStop &&
63 | GTFS Stop code: {gtfsCode || 'none'}, 64 | GTFS Stop id: {gtfsStop?.id} 65 |
} 66 | 67 | {highlightedTrip && <> } 68 | 69 | {gtfsStop && } 70 | 71 | { !gtfsStop &&
72 | This stop is marked in OSM, but has no match in GTFS data. 73 | This stop might be outdated in OSM, check it "on the ground" 74 | and delete it if it doesn't longer exists. 75 |
} 76 | 77 | { !osmStop && gtfsCode &&
78 | This GTFS stop has no matched OSM stop. 79 | Select one of the OSM stops nearby to set 80 | GTFS code in its tags. 81 |
} 82 | 83 | { !osmStop && !gtfsCode &&
84 | This GTFS stop has no matched OSM stop, but it 85 | also doesn't have GTFS code. 86 | If there are no GTFS trips associated with it, 87 | you should probabbly just ignore it. 88 |
} 89 | 90 |

OSM Stop platform:

91 | { platform && } 92 | 93 | 98 | 99 |
100 | } 101 | 102 | 103 | -------------------------------------------------------------------------------- /src/components/Map.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | 3 | import L from 'leaflet'; 4 | import 'leaflet/dist/leaflet.css'; 5 | 6 | import BBOX from '../models/BBOX'; 7 | import './map.css'; 8 | 9 | const osmAttribution = '© OpenStreetMap'; 10 | 11 | const satAttribution = `Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, 12 | Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community`; 13 | 14 | export type LFLT_LatLngLike = [number, number]; 15 | export type LFLT_View = { 16 | center: LFLT_LatLngLike, 17 | zoom: number 18 | }; 19 | 20 | export type LatLon = { 21 | lat: number 22 | lon: number, 23 | }; 24 | 25 | export const MapContext = React.createContext< L.Map | null >(null); 26 | 27 | function latLngBoundsFromBBOX(bbox: BBOX) { 28 | return [ 29 | [bbox.miny, bbox.minx] as L.LatLngTuple, 30 | [bbox.maxy, bbox.maxx] as L.LatLngTuple 31 | ]; 32 | } 33 | 34 | const mapnik = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { 35 | maxNativeZoom: 19, 36 | maxZoom: 20, 37 | attribution: osmAttribution 38 | }); 39 | 40 | 41 | //@ts-ignore 42 | const sat = new L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { 43 | attribution: satAttribution, 44 | maxZoom: 20, 45 | maxNativeZoom: 18, 46 | }); 47 | 48 | export const layers = { 49 | mapnik, sat 50 | } 51 | 52 | const DarkenControl = L.Control.extend({ 53 | onAdd: function(map: L.Map) { 54 | const div = L.DomUtil.create('div'); 55 | div.className = 'll-darken-control'; 56 | div.innerHTML = ''; 57 | 58 | const cb = L.DomUtil.create('input'); 59 | cb.type = 'checkbox'; 60 | cb.onclick = function(e: MouseEvent) { 61 | const target = e.target! as HTMLInputElement; 62 | if(target.checked) { 63 | map.getContainer().classList.add('darken'); 64 | } 65 | else { 66 | map.getContainer().classList.remove('darken'); 67 | } 68 | }; 69 | 70 | div.appendChild(cb); 71 | 72 | return div; 73 | }, 74 | 75 | onRemove: function() { 76 | }, 77 | 78 | }); 79 | 80 | export type MapProps = { 81 | children: React.ReactNode, 82 | center?: LatLon | null, 83 | bbox?: BBOX | null, 84 | satellite?: boolean, 85 | view?: LFLT_View 86 | } 87 | 88 | const Map: React.FC = ({children, view, bbox, center, satellite}) => { 89 | 90 | const divRef = useRef(null); 91 | const [map, setMap] = useState(null); 92 | 93 | useEffect(() => { 94 | const map = L.map(divRef.current!, { 95 | center: [0, 0], 96 | zoom: 3, 97 | layers: [mapnik] 98 | }); 99 | 100 | L.control.layers({ 101 | "OpenStreetMap": mapnik, 102 | "Satellite": sat 103 | }, {}, {collapsed: false}).addTo(map); 104 | 105 | setMap(map); 106 | 107 | new DarkenControl({position: 'topright'}).addTo(map); 108 | 109 | return () => { 110 | map.remove(); 111 | setMap(null); 112 | } 113 | 114 | }, []); 115 | 116 | useEffect(() => { 117 | if (map && view) { 118 | map.setView(view.center, view.zoom); 119 | } 120 | }, [map, view]); 121 | 122 | useEffect(() => { 123 | if (map && center) { 124 | map.panTo(L.latLng(center.lat, center.lon)); 125 | } 126 | }, [map, center]); 127 | 128 | useEffect(() => { 129 | if(map && bbox) { 130 | map.fitBounds(latLngBoundsFromBBOX(bbox)); 131 | } 132 | }, [map, bbox]); 133 | 134 | useEffect(() => { 135 | if(map) { 136 | satellite === true ? sat.addTo(map) : mapnik.addTo(map); 137 | } 138 | }, [map, satellite]); 139 | 140 | return ( 141 |
142 | 143 | {children} 144 | 145 |
146 | ); 147 | } 148 | 149 | export default Map; -------------------------------------------------------------------------------- /src/models/Editor.ts: -------------------------------------------------------------------------------- 1 | import { LatLngLiteral } from "leaflet"; 2 | import { StopMatchData } from "../services/Matcher"; 3 | import { StopMatch } from "../services/Matcher.types"; 4 | import OSMData from "../services/OSMData"; 5 | import { OSMElementTags } from "../services/OSMData.types"; 6 | import OsmStop from "./OsmStop"; 7 | 8 | export const SET_MATCH = 'set_match'; 9 | export const CREATE_NEW = 'create_new'; 10 | export const SET_POSITION = 'set_position'; 11 | 12 | export type EditorActionData = { 13 | latlng?: LatLngLiteral 14 | newMatch?: StopMatch 15 | }; 16 | 17 | export type doneEditCB = (editData: EditorActionData) => void; 18 | 19 | export type EditorActionEnum = 'set_match' | 'create_new' | 'set_position'; 20 | export type EditorRoleEnum = 'platform' | 'stopPosition'; 21 | 22 | export type EditorAction = { 23 | action: EditorActionEnum 24 | match: StopMatch 25 | role: EditorRoleEnum 26 | options: any 27 | data: EditorActionData 28 | }; 29 | 30 | export type EditSubjectType = { 31 | action: EditorActionEnum 32 | match: StopMatch 33 | role: EditorRoleEnum 34 | }; 35 | 36 | export function applyAction( 37 | {action, match, role, options, data}: EditorAction, 38 | osmData: OSMData, 39 | matchData: StopMatchData 40 | ) { 41 | 42 | if (action === CREATE_NEW) { 43 | const name = match?.gtfsStop?.name; 44 | const code = match?.gtfsStop?.code; 45 | 46 | const gtfsRefTag = matchData.settings.refTag; 47 | 48 | const {platformTemplate, stopPositionTemplate} = options; 49 | const latlng = data.latlng; 50 | 51 | if (!latlng) { 52 | return { 53 | success: false, 54 | } 55 | } 56 | 57 | const template = role === 'platform' ? platformTemplate : stopPositionTemplate; 58 | 59 | const osmElement = osmData.createNewNode(latlng, { 60 | name: name, 61 | [gtfsRefTag]: code, 62 | ...template 63 | }); 64 | 65 | const stopPosition = undefined; 66 | const platform = osmElement; 67 | 68 | const osmStop = new OsmStop(stopPosition, platform); 69 | const matchSet = matchData.setMatch(match, osmStop); 70 | 71 | return { 72 | success: matchSet, 73 | matchDataUpdated: matchSet 74 | } 75 | } 76 | 77 | if (action === SET_POSITION) { 78 | const latlng = data.latlng; 79 | 80 | if (!latlng) { 81 | return { 82 | success: false, 83 | } 84 | } 85 | 86 | const osmStop = match.osmStop; 87 | const osmElement = osmStop?.[role]; 88 | 89 | if (osmElement?.type === 'node') { 90 | osmData.setNodeLatLng(latlng, osmElement); 91 | } 92 | 93 | if (osmElement?.type === 'way') { 94 | // Move all nodes of a way 95 | osmData.setWayLatLng(latlng, osmElement); 96 | } 97 | 98 | return { 99 | success: true, 100 | matchDataUpdated: false, 101 | osmDataUpdated: true 102 | } 103 | } 104 | 105 | if (action === SET_MATCH) { 106 | 107 | const newMatch = data.newMatch; 108 | 109 | if (!newMatch) { 110 | return { 111 | success: false, 112 | matchDataUpdated: false, 113 | osmDataUpdated: false 114 | } 115 | } 116 | 117 | const newOsmStop = newMatch.osmStop; 118 | const osmElement = newOsmStop?.[role || 'platform']; 119 | 120 | if (!newOsmStop || !osmElement) { 121 | return { 122 | success: false 123 | }; 124 | } 125 | 126 | const code = match?.gtfsStop?.code; 127 | const gtfsRefTag = matchData.settings.refTag; 128 | 129 | // @ts-ignore 130 | if (osmElement[gtfsRefTag]) { 131 | console.warn(`Overwrite ${gtfsRefTag} on osm element`, osmElement); 132 | } 133 | 134 | const newTags = { 135 | ...osmElement.tags, 136 | [gtfsRefTag]: code 137 | } 138 | 139 | osmData.setElementTags(newTags as OSMElementTags, osmElement); 140 | 141 | return matchData.setMatchToMatch(match, newMatch); 142 | } 143 | 144 | return { 145 | success: false, 146 | error: 'unknow_action' 147 | } 148 | 149 | } -------------------------------------------------------------------------------- /src/components/OsmTags.tsx: -------------------------------------------------------------------------------- 1 | 2 | import { useCallback, useState } from 'react'; 3 | 4 | import OSMData from '../services/OSMData'; 5 | 6 | import classNames from 'classnames'; 7 | import { OSMElement, OSMElementTags } from '../services/OSMData.types'; 8 | import "./OsmTags.css"; 9 | 10 | export type tagsEditCB = (tags: OSMElementTags) => void; 11 | 12 | export type OSMElementTagsProps = { 13 | osmElement: OSMElement 14 | osmData: OSMData 15 | }; 16 | export default function OSMElementTagsEditor({osmElement, osmData}: OSMElementTagsProps) { 17 | const tags = osmElement?.tags; 18 | 19 | // @ts-ignore ignore redraw is never read 20 | const [redraw, setRedraw] = useState({}); 21 | 22 | const handleEdit: tagsEditCB = useCallback(newTags => { 23 | osmData.setElementTags(newTags, osmElement); 24 | setRedraw({}); 25 | }, [tags, osmElement, setRedraw]); 26 | 27 | return 28 | } 29 | 30 | /* TODO: 31 | * replace TagEditorProps tags with TagEditorElement[] 32 | * save them to a local state, 33 | * compose and decompose osmElement.element tags 34 | */ 35 | 36 | // @ts-ignore 37 | type TagEditorElement = { 38 | key: string 39 | value: string 40 | error?: string 41 | } 42 | 43 | type handleInputCB = (key: string, evnt: React.ChangeEvent) => void; 44 | type stringCB = (key: string) => void; 45 | 46 | export type TagEditorProps = { 47 | tags: OSMElementTags 48 | onChange: (newTags: OSMElementTags) => void 49 | protectedKeys?: string[] 50 | invalidKeys?: string[] 51 | }; 52 | export function TagEditor({tags, onChange, protectedKeys, invalidKeys}: TagEditorProps) { 53 | 54 | const handleKeyEdit: handleInputCB = (key, evnt) => { 55 | const entries = Object.entries(tags); 56 | 57 | let newKey = evnt.target.value; 58 | const value = tags[key]; 59 | 60 | // TODO: allow for transient "wrong" tags 61 | if (tags[newKey]) { 62 | newKey += ':'; 63 | } 64 | 65 | const index = entries.findIndex(([k,_v]) => k === key); 66 | entries.splice(index, 1, [newKey, value]); 67 | 68 | onChange(Object.fromEntries(entries)); 69 | }; 70 | 71 | // There is no reason to use useCallback 72 | // this function will be changed on every 73 | // re-render anyways 74 | const handleValueEdit: handleInputCB = (key, evnt) => { 75 | const value = evnt.target.value; 76 | 77 | onChange({ 78 | ...tags, 79 | [key]: value 80 | }); 81 | }; 82 | 83 | const handleAddTag = () => { 84 | const newKey = 'key' 85 | onChange({ 86 | ...tags, 87 | [newKey]: 'value' 88 | }); 89 | }; 90 | 91 | const handleDelete: stringCB = useCallback(tagKey => { 92 | const {[tagKey]: oldValue, ...newTags} = tags; 93 | onChange(newTags); 94 | }, [tags, onChange]); 95 | 96 | const rows = Object.entries(tags || {}).map(([key, value], i) => { 97 | const readonly = protectedKeys?.includes(key); 98 | const invalid = invalidKeys?.includes(key); 99 | return ( 100 | 101 | 102 | {!readonly && 106 | remove_circle_outline 107 | } 108 | 109 | 110 | 111 | 115 | 116 | 117 | 118 | 122 | 123 | 124 | ); 125 | }); 126 | 127 | return (<>{ 128 | tags && 129 | 130 | {rows} 131 | 132 | 136 | 137 | 138 | 139 | 140 |
133 | add_circle_outline 135 |
141 | }); 142 | 143 | } 144 | -------------------------------------------------------------------------------- /src/components/MatchList.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import GTFSData from '../models/GTFSData'; 3 | import StopListElement from './StopListElement'; 4 | 5 | import { ListFiltersType } from '../models/Filters'; 6 | import { loadInJOSM } from '../services/JOSMRemote'; 7 | import { StopMatchData } from '../services/Matcher'; 8 | import { StopMatch } from '../services/Matcher.types'; 9 | import { OSMElement } from '../services/OSMData.types'; 10 | 11 | import './match-list.css'; 12 | 13 | type inputChangeCB = (e: React.ChangeEvent) => void; 14 | type FilterKey = 'showMatched' | 'showUnmatchedGtfs' | 'showUnmatchedOsm'; 15 | 16 | export type MatchListProps = { 17 | matchData: StopMatchData 18 | gtfsData: GTFSData 19 | filters: ListFiltersType 20 | setFilters: (filters: ListFiltersType) => any 21 | filteredMatches?: StopMatch[] 22 | selectedMatch?: StopMatch 23 | selectMatch?: (match: StopMatch) => void 24 | }; 25 | 26 | export default function MatchList({ 27 | matchData, gtfsData, 28 | filters, setFilters, 29 | filteredMatches, 30 | selectedMatch, selectMatch}: MatchListProps) { 31 | 32 | // TODO: move to FilterCheckbox 33 | const filterChangeHandler = useCallback(event => { 34 | const key = event.target.getAttribute('data-key') as FilterKey; 35 | const value = !filters[key]; 36 | setFilters({ 37 | ...filters, 38 | [key]: value 39 | }); 40 | }, [filters, setFilters]); 41 | 42 | const openListInJOSM = useCallback(() => { 43 | if (filteredMatches) { 44 | const osmElements: OSMElement[] = []; 45 | 46 | filteredMatches.forEach(m => { 47 | m.osmStop?.stopPosition && osmElements.push(m.osmStop?.stopPosition); 48 | m.osmStop?.platform && osmElements.push(m.osmStop?.platform); 49 | }); 50 | 51 | loadInJOSM(osmElements); 52 | } 53 | 54 | }, [filteredMatches]); 55 | 56 | const stops = filteredMatches?.map(match => { 57 | const key = match.gtfsStop?.id || match.osmStop?.getId(); 58 | const selected = selectedMatch === match; 59 | return { 63 | console.log('selectMatch', match); 64 | selectMatch && selectMatch(match)} 65 | }> 66 | 67 | }); 68 | 69 | return (
70 |
71 | 76 |
77 | 83 |
84 |
85 | 91 |
92 |
93 | 99 |
100 |
101 | 102 |
103 |
104 |
105 | { stops } 106 |
107 |
); 108 | 109 | } 110 | 111 | type FilterCheckboxProps = { 112 | filterKey: FilterKey 113 | label?: string 114 | filters: ListFiltersType 115 | onChange: (e: React.ChangeEvent) => void 116 | }; 117 | function FilterCheckbox({filterKey, label, filters, onChange}: FilterCheckboxProps) { 118 | return <> 119 | 120 | 126 | 127 | } 128 | 129 | type selectChangeCB = (evnt: React.ChangeEvent) => void; 130 | 131 | type RouteStopsFilterProps = { 132 | gtfsData: GTFSData 133 | filters: ListFiltersType 134 | setFilters: (filters: ListFiltersType) => void 135 | }; 136 | function RouteStopsFilter({gtfsData, filters, setFilters}: RouteStopsFilterProps) { 137 | 138 | const options = gtfsData && Object.values(gtfsData.routes).map(route => { 139 | const routeKey = `r${route.id}`; 140 | 141 | const routeOption = 142 | ; 145 | 146 | const tripOptions = route.trips.map((trip, i) => { 147 | const tripKey = `r${route.id} t${i}`; 148 | return 149 | }); 150 | 151 | return [routeOption, ...tripOptions]; 152 | }); 153 | 154 | const selectionChange = useCallback(evnt => { 155 | const key = evnt.target.value; 156 | const routeOrTrip = decodeSelectKey(gtfsData, key); 157 | 158 | setFilters({ 159 | ...filters, 160 | filterBy: routeOrTrip ? [routeOrTrip] : [] 161 | }); 162 | }, [filters, setFilters, gtfsData]); 163 | 164 | return
165 | 166 | 170 |
171 | } 172 | 173 | function decodeSelectKey(gtfsData: GTFSData, key: string) { 174 | if (key === 'none') { 175 | return undefined; 176 | } 177 | 178 | 179 | const tripMatch = key.match(/r(.*?) t([\d]+)/); 180 | if (tripMatch) { 181 | const routeId = tripMatch[1]; 182 | const tripIndex = parseInt(tripMatch[2]); 183 | 184 | const route = gtfsData.routes[routeId] 185 | if (route) { 186 | return route.trips[tripIndex]; 187 | } 188 | } 189 | 190 | const routeMatch = key.match(/r(.*)/); 191 | if (routeMatch) { 192 | const routeId = routeMatch[1]; 193 | return gtfsData.routes[routeId]; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/models/GTFSData.ts: -------------------------------------------------------------------------------- 1 | import * as Papa from 'papaparse'; 2 | import { LonLat } from '../services/OSMData.types'; 3 | import BBOX from './BBOX'; 4 | import { lonLatToMerc } from './MercatorUtil'; 5 | 6 | type RawData = {data: any} 7 | type RawCSV = any; 8 | 9 | export default class GTFSData { 10 | stops: GTFSStop[] 11 | bbox: BBOX | null 12 | routes: {[routeId: string]: GTFSRoute} 13 | stopById: {[stopId: string]: GTFSStop} 14 | stopToTrips: {[stopId: string]: GTFSTripUnion[]} 15 | 16 | constructor() { 17 | this.stops = []; 18 | this.routes = {}; 19 | this.stopById = {}; 20 | this.stopToTrips = {}; 21 | this.bbox = null; 22 | } 23 | 24 | 25 | loadStops(rawdata: RawCSV) { 26 | let sampled = false; 27 | Papa.parse(rawdata, { 28 | header: true, 29 | step: ({data: stopData}: RawData) => { 30 | if ( !stopData.stop_id ) { 31 | return; 32 | } 33 | 34 | if (!sampled) { 35 | sampled = true; 36 | console.log('Stop raw data sample', stopData); 37 | } 38 | 39 | const stop = new GTFSStop(stopData); 40 | 41 | const {lon, lat} = stop; 42 | if (!Number.isNaN(lon) && !Number.isNaN(lat)) { 43 | this.stops.push(stop); 44 | this.stopById[stop.id] = stop; 45 | } 46 | else { 47 | console.error('Failed to parse stop coordinates', stop); 48 | } 49 | } 50 | }); 51 | 52 | this.bbox = new BBOX(this.stops[0].lon, this.stops[0].lat, this.stops[0].lon, this.stops[0].lat); 53 | this.stops.forEach(stop => this.bbox?.extend(stop.lon, stop.lat)); 54 | } 55 | 56 | loadRoutes(rawRoutes: RawCSV, rawTrips: RawCSV, rawStopTimes: RawCSV) { 57 | let routeDataSample = false; 58 | Papa.parse(rawRoutes, { 59 | header: true, 60 | step: ({data: routeData}: RawData) => { 61 | if (!routeDataSample) { 62 | routeDataSample = true; 63 | console.log('Route raw data sample', routeData); 64 | } 65 | 66 | const route = new GTFSRoute(routeData); 67 | if (route.id) { 68 | this.routes[route.id] = route; 69 | } 70 | } 71 | }); 72 | 73 | const tripById: {[tripId: string]: GTFSTrip} = {}; 74 | 75 | let tripDataSample = false; 76 | Papa.parse(rawTrips, { 77 | header: true, 78 | step: ({data: tripData}: RawData) => { 79 | if (!tripDataSample) { 80 | tripDataSample = true; 81 | console.log('Trip raw data sample', tripData); 82 | } 83 | const trip = new GTFSTrip(tripData); 84 | tripById[trip.id] = trip; 85 | } 86 | }); 87 | 88 | Papa.parse(rawStopTimes, { 89 | header: true, 90 | step: ({data: stopTimeData}: RawData) => { 91 | const {stop_id, trip_id, stop_sequence} = stopTimeData; 92 | 93 | const trip = tripById[trip_id]; 94 | const stop = this.stopById[stop_id]; 95 | 96 | if(trip && stop) { 97 | trip.stopSequence?.push([stop_sequence, stop] as StopSeqTuple); 98 | } 99 | } 100 | }); 101 | 102 | Object.values(tripById).forEach(trip => trip.sortSequence()); 103 | 104 | const tripUnionsById: {[key: string]: GTFSTripUnion} = {}; 105 | Object.values(tripById).forEach(trip => { 106 | if(tripUnionsById[trip.sequenceId]) { 107 | tripUnionsById[trip.sequenceId].addTrip(trip); 108 | } 109 | else { 110 | tripUnionsById[trip.sequenceId] = new GTFSTripUnion(trip); 111 | } 112 | }); 113 | 114 | Object.values(tripUnionsById).forEach(trip => { 115 | const route = this.routes[trip.routeId]; 116 | if (route) { 117 | route.trips.push(trip); 118 | } 119 | }); 120 | 121 | Object.values(this.routes).forEach(route => { 122 | route.trips.forEach(trip => { 123 | trip.stopSequence.forEach(stop => { 124 | if (!this.stopToTrips[stop.id]) { 125 | this.stopToTrips[stop.id] = []; 126 | } 127 | this.stopToTrips[stop.id].push(trip); 128 | }); 129 | }); 130 | }); 131 | } 132 | } 133 | 134 | export class GTFSTripUnion { 135 | 136 | sequenceId: string 137 | 138 | blockId: string 139 | routeId: string 140 | directionId: string 141 | headSign: string 142 | 143 | tripIds: string[] 144 | stopSequence: GTFSStop[] 145 | 146 | 147 | constructor(trip: GTFSTrip) { 148 | const tripRouteId = trip.routeId + trip.headSign + trip.directionId; 149 | this.sequenceId = trip.sequenceId || tripRouteId; 150 | 151 | this.stopSequence = trip.stopSequence.map(s => s[1]); 152 | 153 | this.tripIds = [trip.id]; 154 | 155 | this.blockId = trip.directionId; 156 | this.routeId = trip.routeId; 157 | 158 | this.directionId = trip.directionId; 159 | 160 | this.headSign = trip.headSign; 161 | } 162 | 163 | addTrip(trip: GTFSTrip) { 164 | if (this.sequenceId === trip.sequenceId) { 165 | this.tripIds.push(trip.id); 166 | } 167 | } 168 | } 169 | 170 | type StopSeqTuple = [number, GTFSStop]; 171 | 172 | export class GTFSTrip { 173 | id: string 174 | directionId: string 175 | blockId: string 176 | routeId: string 177 | headSign: string 178 | 179 | stopSequence: StopSeqTuple[] 180 | sequenceId: string 181 | 182 | constructor(data: any) { 183 | const { direction_id, block_id, route_id, trip_id, trip_headsign } = data; 184 | 185 | this.id = trip_id; 186 | this.directionId = direction_id; 187 | this.blockId = block_id; 188 | this.routeId = route_id; 189 | 190 | this.headSign = trip_headsign; 191 | 192 | this.stopSequence = []; 193 | 194 | this.sequenceId = this.routeId + this.headSign + this.directionId; 195 | } 196 | 197 | sortSequence() { 198 | this.stopSequence.sort((a, b) => a[0] - b[0]); 199 | this.sequenceId = this.stopSequence.map(s => s[1].id).join(); 200 | } 201 | } 202 | 203 | export class GTFSRoute { 204 | id: string 205 | shortName: string 206 | longName: string 207 | trips: GTFSTripUnion[] 208 | 209 | constructor(data: any) { 210 | const { route_id, route_long_name, route_short_name } = data; 211 | this.id = route_id; 212 | this.shortName = route_short_name; 213 | this.longName = route_long_name; 214 | 215 | this.trips = []; 216 | } 217 | } 218 | 219 | export class GTFSStop { 220 | id: string 221 | code: string 222 | lon: number 223 | lat: number 224 | position: L.Point 225 | name: string 226 | 227 | constructor(data: any) { 228 | const { parent_station, stop_code, stop_id, stop_name } = data; 229 | 230 | if (parent_station) { 231 | console.log(`Stop ${stop_code} has parent station ${parent_station}`) 232 | } 233 | 234 | const { stop_lat, stop_lon } = data; 235 | 236 | this.lon = parseFloat(stop_lon); 237 | this.lat = parseFloat(stop_lat); 238 | 239 | this.position = lonLatToMerc(this.lon, this.lat); 240 | 241 | this.id = stop_id; 242 | this.code = stop_code; 243 | this.name = stop_name; 244 | } 245 | 246 | getLonLat() { 247 | return {lon: this.lon, lat:this.lat} as LonLat; 248 | } 249 | 250 | } -------------------------------------------------------------------------------- /src/components/RouteMatch.tsx: -------------------------------------------------------------------------------- 1 | 2 | import classNames from "classnames"; 3 | import { useCallback, useState } from "react"; 4 | import { ListFiltersType } from "../models/Filters"; 5 | import { GTFSRoute, GTFSTripUnion } from "../models/GTFSData"; 6 | import { OsmRoute } from "../models/OsmRoute"; 7 | import { loadInJOSM } from "../services/JOSMRemote"; 8 | import { StopMatchData } from "../services/Matcher"; 9 | import { RouteMatchType, StopMatch } from "../services/Matcher.types"; 10 | import { OSMRelation } from "../services/OSMData.types"; 11 | import "./RouteMatch.css"; 12 | import { IRouteTripsEditorState } from "./RouteTripsEditor"; 13 | 14 | type RouteMatchProps = { 15 | routeMatch: RouteMatchType, 16 | stopMatchData: StopMatchData, 17 | } & IMatchesFilter & IRouteTripsEditorState; 18 | export default function RouteMatch({routeMatch, stopMatchData, ...props}: RouteMatchProps) { 19 | const {gtfsRoute, osmRoute} = routeMatch; 20 | 21 | const id = gtfsRoute?.id || osmRoute?.ref; 22 | 23 | const gtfsRouteTitle = getGTFSRouteName(gtfsRoute); 24 | 25 | const { setRouteEditorSubj } = props; 26 | 27 | const selectCB = useCallback(() => { 28 | setRouteEditorSubj({ 29 | routeMatch 30 | }); 31 | }, [routeMatch, setRouteEditorSubj]); 32 | 33 | const routeStops: StopMatch[] = []; 34 | gtfsRoute?.trips?.forEach(gtfsTrip => { 35 | gtfsTrip.stopSequence.forEach(s => { 36 | const match = stopMatchData.matchByGtfsId[s.id]; 37 | routeStops.push(match); 38 | }); 39 | }); 40 | 41 | const matchedStops = routeStops.filter(s => s.osmStop && s.gtfsStop).length; 42 | 43 | return <> 44 |
45 | {gtfsRouteTitle &&
{ gtfsRouteTitle }
} 46 | { 47 | // @ts-ignore 48 | osmRoute &&
49 | 50 | {id && osmRoute?.name?.replace(id, '')} 51 |
52 | } 53 |
54 | {matchedStops} out of {routeStops.length} stops matched 55 |
56 |
57 | 58 | 59 | } 60 | 61 | export function getGTFSRouteName(gtfsRoute?: GTFSRoute) { 62 | return gtfsRoute && (`${gtfsRoute?.shortName} - ${gtfsRoute?.longName}`); 63 | } 64 | 65 | type TripsDataProps = { 66 | gtfsRoute?: GTFSRoute 67 | osmRoute?: OsmRoute 68 | stopMatchData?: StopMatchData, 69 | } & IMatchesFilter; 70 | function TripsData({gtfsRoute, osmRoute, stopMatchData, filters, setFilters}: TripsDataProps) { 71 | const gtfsTrips = gtfsRoute?.trips; 72 | const osmTrips = osmRoute?.tripRelations; 73 | 74 | const [showTrips, setShowTrips] = useState(false); 75 | 76 | const stopSequenceCmp = (t1: GTFSTripUnion, t2: GTFSTripUnion) => { 77 | const hs1 = t1.headSign; 78 | const hs2 = t2.headSign; 79 | 80 | if (hs1 === hs2) { 81 | return t2.stopSequence.length - t1.stopSequence.length 82 | } 83 | 84 | return hs1.localeCompare(hs2); 85 | }; 86 | 87 | const onGTFSTripSelect = useCallback<(trip: GTFSTripUnion) => void>(gtfsTrip => { 88 | if (!setFilters || !filters) { 89 | return; 90 | } 91 | 92 | setFilters({ 93 | ...filters, 94 | filterBy: [gtfsTrip] 95 | }); 96 | }, [stopMatchData, filters, setFilters]); 97 | 98 | const onOSMTripSelect = useCallback<(trip: OSMRelation) => void>(osmTrip => { 99 | 100 | if (!setFilters || !filters) { 101 | return; 102 | } 103 | 104 | setFilters({ 105 | ...filters, 106 | filterBy: [osmTrip] 107 | }); 108 | }, [stopMatchData, setFilters]); 109 | 110 | const gtfsTripsElements = gtfsTrips?.sort(stopSequenceCmp)?.map(gtfsTrip => { 111 | return ; 114 | }); 115 | 116 | const osmTripsElements = osmTrips?.map(osmTrip => { 117 | return ; 120 | }); 121 | 122 | const osmMasterRelation = osmRoute?.masterRelation; 123 | 124 | return (
125 |
126 | {gtfsRoute && GTFS Trips: {gtfsRoute?.trips?.length}} 127 | {osmRoute && OSM Trips: {osmRoute?.tripRelations?.length}} 128 |
129 |
setShowTrips(!showTrips)}>{showTrips ? 'hide trips' : 'show trips'}
130 | {showTrips &&
GTFS trips:
} 131 | {showTrips && gtfsRoute && gtfsTripsElements} 132 | {showTrips &&
OSM trips:
} 133 | {showTrips && osmMasterRelation && 134 |
OSM Master relation: {osmMasterRelation.tags.name}
135 | } 136 | {showTrips && osmTripsElements} 137 |
); 138 | } 139 | 140 | type IMatchesFilter = { 141 | filters?: ListFiltersType 142 | setFilters?: (filters: ListFiltersType) => void 143 | } 144 | 145 | type ISelectable = { 146 | selected?: boolean 147 | onSelect?: () => void 148 | } 149 | 150 | type GTFSTripDisplayProps = { 151 | gtfsTrip: GTFSTripUnion 152 | } 153 | & ISelectable 154 | function GTFSTripDisplay({gtfsTrip, selected, onSelect}: GTFSTripDisplayProps) { 155 | const stopSequence = gtfsTrip.stopSequence; 156 | 157 | const classes = classNames( 158 | 'route-match-gtfs-trip', 159 | {selected, "selectable": onSelect !== undefined}); 160 | 161 | return
162 | { gtfsTrip.headSign && {gtfsTrip.headSign} } 163 | { gtfsTrip.headSign && } 164 | {`(${stopSequence.length}) stops`} 165 |
; 166 | } 167 | 168 | type OSMTripDisplayProps = { 169 | osmTrip: OSMRelation 170 | } 171 | & ISelectable 172 | function OSMTripDisplay({osmTrip, selected, onSelect}: OSMTripDisplayProps) { 173 | 174 | const platforms = osmTrip?.members?.filter(m => m.role === 'platform' || m.role === 'stop_position'); 175 | const roadSegments = osmTrip?.members?.filter(m => m.role !== 'platform' && m.type === 'way'); 176 | 177 | const loadCB = () => { 178 | loadInJOSM([osmTrip], false, false, true); 179 | }; 180 | 181 | const classes = classNames( 182 | 'route-match-osm-trip', 183 | {selected, "selectable": onSelect !== undefined}); 184 | 185 | return
186 | 187 | { osmTrip.tags.name } 188 | 189 | {`(${platforms.length} stops, ${roadSegments.length} ways)`} 190 | 191 | 192 | Load in JOSM 193 |
; 194 | } 195 | 196 | export type RoutesMatchFiltersProps = { 197 | filters: ListFiltersType 198 | setFilters: (filters: ListFiltersType) => any 199 | } 200 | export function RoutesMatchFilters({filters, setFilters}:RoutesMatchFiltersProps) { 201 | 202 | const activeFilters = filters.filterBy.map(filter => { 203 | console.log(filter); 204 | 205 | if(filter instanceof GTFSRoute) { 206 | return
GTFS Route: {filter.shortName}
207 | } 208 | else if (filter instanceof GTFSTripUnion) { 209 | return
GTFS Trip: {filter.headSign}
210 | } 211 | else if (filter instanceof OsmRoute) { 212 | return
OSM Route: {filter.name}
213 | } 214 | else if ((filter as OSMRelation).type === 'relation') { 215 | return
OSM Trip: {(filter as OSMRelation).tags.name}
216 | } 217 | return
Unknow filter
218 | }); 219 | 220 | const clearFilters = useCallback(() => { 221 | setFilters({ 222 | ...filters, 223 | filterBy: [] 224 | }); 225 | }, [filters, setFilters]); 226 | 227 | return
228 | { activeFilters.length > 0 &&
Stops are filtered by
} 229 | {activeFilters} 230 | { activeFilters.length > 0 && } 231 |
232 | } 233 | -------------------------------------------------------------------------------- /src/services/OSMData.ts: -------------------------------------------------------------------------------- 1 | import L from 'leaflet'; 2 | import BBOX from "../models/BBOX"; 3 | import { ROUTE_TYPES } from "./Matcher"; 4 | import { LonLatTuple, OSMElement, OSMElementTags, OSMNode, OSMRelation, OSMWay } from "./OSMData.types"; 5 | 6 | const stopsQ: string = ` 7 | [out:json][timeout:900]; 8 | ( 9 | node["public_transport"="platform"]({{bbox}}); 10 | way["public_transport"="platform"]({{bbox}}); 11 | 12 | node["public_transport"="stop_position"]({{bbox}}); 13 | 14 | node["highway"="bus_stop"]({{bbox}}); 15 | node["highway"="platform"]({{bbox}}); 16 | 17 | node["amenity"="bus_station"]({{bbox}}); 18 | way["amenity"="bus_station"]({{bbox}}); 19 | 20 | node["railway"="tram_stop"]({{bbox}}); 21 | node["railway"="platform"]({{bbox}}); 22 | way["railway"="platform"]({{bbox}}); 23 | ); 24 | out meta; 25 | >; 26 | out meta qt; 27 | `; 28 | 29 | const routesQ: string = ` 30 | [out:json][timeout:900]; 31 | ( 32 | relation["route"~"^(${ ROUTE_TYPES.join('|') })$"]({{bbox}}); 33 | relation["type"="route_master"]({{bbox}}); 34 | ); 35 | out meta; 36 | >; 37 | out meta qt; 38 | `; 39 | 40 | const endpoint = 'https://overpass-api.de/api/interpreter'; 41 | 42 | export async function queryStops(bbox: BBOX) { 43 | const bboxString = getBBOXString(bbox); 44 | const query = stopsQ.replaceAll('{{bbox}}', bboxString); 45 | 46 | return queryOverpass(query); 47 | } 48 | 49 | export async function queryRoutes(bbox: BBOX) { 50 | const bboxString = getBBOXString(bbox); 51 | const query = routesQ.replaceAll('{{bbox}}', bboxString); 52 | 53 | return queryOverpass(query); 54 | } 55 | 56 | function getBBOXString(bbox: BBOX) { 57 | // min_lat, min_lon, max_lat, max_lon 58 | return `${bbox.miny},${bbox.minx},${bbox.maxy},${bbox.maxx}`; 59 | } 60 | 61 | async function queryOverpass(query: string) { 62 | const response = await fetch(endpoint, { 63 | method: "POST", 64 | mode: "cors", 65 | cache: "no-cache", 66 | headers: { 67 | 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 68 | }, 69 | body: `data=${encodeURIComponent(query)}` 70 | }); 71 | 72 | return await response.json(); 73 | } 74 | 75 | export function getElementLonLat(e: OSMElement) { 76 | if (e.type === 'node') { 77 | return [e.lon, e.lat] as LonLatTuple; 78 | } 79 | 80 | if (e.type === 'way') { 81 | const nodes = e.nodes 82 | .map(nid => instance.getNodeById(nid)) 83 | .filter(n => n !== undefined); 84 | 85 | if (nodes.length === 0) { 86 | console.log('Failed to load nodes for way', e); 87 | return undefined; 88 | } 89 | 90 | try { 91 | const llngs = nodes.map(({lat, lon}) => ({lat, lng: lon})); 92 | // @ts-ignore 93 | const center = new L.LatLngBounds(llngs).getCenter(); 94 | 95 | return [center.lng, center.lat] as LonLatTuple; 96 | } 97 | catch (err) { 98 | console.log('Failed to get geometry for way', e, nodes); 99 | console.error('Failed to get geometry for way', err); 100 | } 101 | } 102 | 103 | if (e.type === 'relation') { 104 | 105 | } 106 | 107 | } 108 | 109 | export function lonLatToLatLng(lonLat: LonLatTuple) { 110 | if (!lonLat) { 111 | return undefined; 112 | } 113 | 114 | const [lng, lat] = lonLat; 115 | 116 | return {lng, lat}; 117 | } 118 | 119 | type LatLngLiteral = {lat: number, lng: number}; 120 | export type OsmElementFilter = (e: OSMElement) => boolean; 121 | 122 | export type OSMDataChange = { 123 | element: OSMElement 124 | original: OSMElement 125 | action: string[] 126 | }; 127 | export type ElementChangeAction = { 128 | element: OSMElement 129 | action: string 130 | }; 131 | export type TagStatistics = Map; 132 | 133 | export default class OSMData { 134 | idMap: Map 135 | newIdCounter: number 136 | elements: OSMElement[] 137 | changes: OSMDataChange[] 138 | 139 | constructor() { 140 | this.newIdCounter = -1; 141 | this.changes = []; 142 | 143 | this.elements = []; 144 | this.idMap = new Map(); 145 | } 146 | 147 | static getInstance() { 148 | return instance; 149 | } 150 | 151 | calculateTagStatistics(filter: OsmElementFilter) { 152 | const stats = new Map(); 153 | 154 | const elements = filter ? this.elements.filter(filter) : this.elements; 155 | 156 | elements.forEach(element => { 157 | element.tags && Object.keys(element.tags).forEach(key => { 158 | // @ts-ignore 159 | const occurances = stats.has(key) ? stats.get(key) + 1 : 1; 160 | stats.set(key, occurances); 161 | }); 162 | }); 163 | 164 | return stats; 165 | } 166 | 167 | updateOverpassData(overpassData: OSMData) { 168 | overpassData.elements.forEach(e => { 169 | this.updateElement(e); 170 | }); 171 | } 172 | 173 | updateElement(element: OSMElement) { 174 | const {id, type} = element; 175 | const key = `${type}${id}`; 176 | 177 | if (this.idMap.has(key)) { 178 | // TBD, check for edits 179 | } 180 | else { 181 | this.addElement(element); 182 | } 183 | 184 | } 185 | 186 | createNewNode({lat, lng}: LatLngLiteral, tags: OSMElementTags) { 187 | 188 | const element = { 189 | // In OSM data files negative IDs are used for newly created elements 190 | id: this.newIdCounter--, 191 | type: 'node', 192 | lon: lng, 193 | lat: lat, 194 | tags 195 | } as OSMNode; 196 | 197 | this.addElement(element); 198 | 199 | this.changes.push({ 200 | 'element': element, 201 | original: createElementCopy(element), 202 | action: ['create'] 203 | }); 204 | 205 | return element; 206 | } 207 | 208 | setNodeLatLng(latlng: LatLngLiteral, osmElement: OSMElement) { 209 | this.commitAction({ 'element': osmElement, action: 'update_position' }); 210 | 211 | const {lat, lng} = latlng; 212 | 213 | if (osmElement.type === 'node') { 214 | osmElement.lat = lat; 215 | osmElement.lon = lng; 216 | } 217 | } 218 | 219 | // @ts-expect-error 220 | setWayLatLng(latlng: LatLngLiteral, osmElement: OSMElement) { 221 | // TODO: 222 | console.warn('setWayLatLng not implemented'); 223 | } 224 | 225 | setElementTags(tags: OSMElementTags, osmElement: OSMElement) { 226 | this.commitAction({ 'element': osmElement, action: 'change_tags' }); 227 | 228 | const validTags = Object.entries(tags) 229 | .filter(([k, v]) => !isBlank(k) && !isBlank(v)); 230 | 231 | osmElement.tags = Object.fromEntries(validTags); 232 | } 233 | 234 | addElement(element: OSMElement) { 235 | const {id, type} = element; 236 | const key = `${type}${id}`; 237 | 238 | this.elements.push(element); 239 | this.idMap.set(key, element); 240 | } 241 | 242 | commitAction({element, action}: ElementChangeAction) { 243 | const existing = this.changes.find(change => 244 | change.element.id === element.id && 245 | change.element.type === element.type 246 | ); 247 | 248 | if (existing) { 249 | !existing.action.includes(action) && existing.action.push(action); 250 | } 251 | else { 252 | this.changes.push({ 253 | element, 254 | original: createElementCopy(element), 255 | action: [action] 256 | }); 257 | } 258 | } 259 | 260 | getNodeById(id: number) { 261 | return this.getByTypeAndId('node', id) as OSMNode; 262 | } 263 | 264 | getWayById(id: number) { 265 | return this.getByTypeAndId('way', id) as OSMWay; 266 | } 267 | 268 | getRelationById(id: number) { 269 | return this.getByTypeAndId('relation', id) as OSMRelation; 270 | } 271 | 272 | getByTypeAndId(type: string, id: number) { 273 | const key = `${type}${id}`; 274 | return this.idMap.get(key); 275 | } 276 | 277 | } 278 | 279 | function createElementCopy(element: OSMElement) { 280 | const copy = { 281 | ...element, 282 | tags: { 283 | ...element.tags 284 | } 285 | }; 286 | 287 | if (element.type === 'way') { 288 | return { 289 | ...copy, 290 | nodes: [...element.nodes] 291 | } as OSMWay; 292 | } 293 | 294 | if (element.type === 'relation') { 295 | return { 296 | ...copy, 297 | members: element.members.map(m => {return {...m}}) 298 | } as OSMRelation; 299 | } 300 | 301 | return copy as OSMElement; 302 | } 303 | 304 | function isBlank(str: string) { 305 | return str === undefined || str === null || /^\s*$/.test(str); 306 | } 307 | 308 | // OSM Data is a Singleton 309 | const instance = new OSMData(); 310 | -------------------------------------------------------------------------------- /src/services/Matcher.ts: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | 3 | import KDBush from 'kdbush'; 4 | import GTFSData, { GTFSStop } from '../models/GTFSData'; 5 | import { OsmRoute } from '../models/OsmRoute'; 6 | import OsmStop from '../models/OsmStop'; 7 | import { MatchSettingsType, RouteMatchType, StopMatch } from './Matcher.types'; 8 | import OSMData from './OSMData'; 9 | import { OSMElement, OSMRelation } from './OSMData.types'; 10 | 11 | // Meters 12 | const SEARCH_RADIUS = 500; 13 | 14 | export const ROUTE_TYPES = [ 15 | 'public_transport', 'bus', 'tram', 16 | 'light_rail', 'ferry', 'trolleybus' 17 | ]; 18 | 19 | function parseStopsFromOSMData(osmData: OSMData) { 20 | const hwBusStop = osmData.elements.filter(e => e.tags) 21 | .filter(e => e.tags['highway'] === 'bus_stop' || e.tags['public_transport'] === 'platform'); 22 | 23 | return hwBusStop.map(e => { 24 | try { 25 | return new OsmStop(undefined, e); 26 | } 27 | catch (err) { 28 | console.error('Failed to create OsmStop for', e); 29 | return undefined; 30 | } 31 | }); 32 | } 33 | 34 | export class StopMatchData { 35 | 36 | settings: MatchSettingsType 37 | matched: StopMatch[] 38 | unmatchedGtfs: StopMatch[] 39 | unmatchedOsm: StopMatch[] 40 | 41 | matchByGtfsId: {[gtfsStopId: string]: StopMatch} 42 | matchByOSMId: {[osmStopId: string]: StopMatch} 43 | 44 | constructor(settings: MatchSettingsType, gtfsData: GTFSData, osmData: OSMData) { 45 | this.settings = { 46 | ...settings 47 | }; 48 | 49 | this.matched = []; 50 | this.unmatchedGtfs = []; 51 | this.unmatchedOsm = []; 52 | 53 | this._runMatch(gtfsData, osmData); 54 | 55 | this.matchByGtfsId = {}; 56 | [...this.matched, ...this.unmatchedGtfs].forEach(match => { 57 | if (match.gtfsStop) { 58 | this.matchByGtfsId[match.gtfsStop.id] = match; 59 | } 60 | }); 61 | 62 | this.matchByOSMId = {}; 63 | [...this.matched, ...this.unmatchedOsm].forEach(match => { 64 | if (match.osmStop) { 65 | const platform = match.osmStop.platform; 66 | if (platform) { 67 | this.matchByOSMId[`${platform.type[0]}${platform.id}`] = match; 68 | } 69 | 70 | const stopPosition = match.osmStop.stopPosition; 71 | if (stopPosition) { 72 | this.matchByOSMId[`${stopPosition.type[0]}${stopPosition.id}`] = match; 73 | } 74 | } 75 | }); 76 | } 77 | 78 | _runMatch(gtfsData: GTFSData, osmData: OSMData) { 79 | 80 | const osmStops = parseStopsFromOSMData(osmData); 81 | 82 | const indexEntries = osmStops.map(stop => ({ 83 | stop, 84 | point: stop?.getPosition3857() 85 | })) 86 | .filter(entry => entry.point && entry.stop); 87 | 88 | 89 | const stopsIndex = new KDBush(indexEntries.length); 90 | indexEntries.forEach(({point}) => {point && stopsIndex.add(point.x, point.y)}) 91 | 92 | stopsIndex.finish(); 93 | 94 | const refTag = this.settings.refTag; 95 | 96 | const matchedOsmStops = new Set(); 97 | 98 | gtfsData.stops.forEach(gtfsStop => { 99 | const surroundOsmStops = stopsIndex 100 | .within(gtfsStop.position.x, gtfsStop.position.y, SEARCH_RADIUS) 101 | .map(i => indexEntries[i].stop) as OsmStop[]; 102 | 103 | const match = surroundOsmStops ? findMatch(gtfsStop, surroundOsmStops, refTag) : undefined; 104 | 105 | if (match) { 106 | this.matched.push(match); 107 | matchedOsmStops.add(match.osmStop); 108 | } 109 | else { 110 | this.unmatchedGtfs.push({ 111 | id: gtfsStop.id, 112 | gtfsStop: gtfsStop, 113 | osmStop: undefined 114 | }); 115 | } 116 | }); 117 | 118 | osmStops 119 | .filter(s => !matchedOsmStops.has(s)) 120 | .forEach(osmStop => this.unmatchedOsm.push({ 121 | id: osmStop!.getId(), 122 | osmStop 123 | })); 124 | 125 | } 126 | 127 | setMatch(match: StopMatch, osmStop: OsmStop) { 128 | const gtfsStop = match.gtfsStop; 129 | const code = gtfsStop!.code; 130 | 131 | const {platform, stopPosition} = osmStop; 132 | 133 | const platformMatch = platform && checkCodeForElement(platform, this.settings.refTag, code); 134 | const pstopPositionMatch = stopPosition && checkCodeForElement(stopPosition, this.settings.refTag, code); 135 | 136 | if (platformMatch || pstopPositionMatch) { 137 | match.codeMatch = { 138 | platform: !!platformMatch, 139 | stopPosition: !!pstopPositionMatch, 140 | }; 141 | match.osmStop = osmStop; 142 | 143 | this.matched.push(match); 144 | this.unmatchedGtfs = this.unmatchedGtfs.filter(m => m.gtfsStop!.id !== match.gtfsStop!.id); 145 | 146 | return true; 147 | } 148 | 149 | return false; 150 | } 151 | 152 | setMatchToMatch(match: StopMatch, assignedMatch: StopMatch) { 153 | const osmStop = assignedMatch.osmStop; 154 | 155 | if (!match.gtfsStop || !osmStop) { 156 | return false; 157 | } 158 | 159 | if (this.setMatch(match, osmStop)) { 160 | this.unmatchedGtfs = this.unmatchedGtfs.filter(m => m.gtfsStop!.id !== match.gtfsStop!.id); 161 | 162 | this.unmatchedOsm = this.unmatchedOsm.filter(m => m.osmStop !== osmStop); 163 | 164 | return true; 165 | } 166 | 167 | return false; 168 | } 169 | 170 | findMatchByOsmElementTypeAndId(type: string, id: number) { 171 | return this.matchByOSMId[`${type[0]}${id}`]; 172 | } 173 | } 174 | 175 | function findMatch(gtfsStop: GTFSStop, surroundOsmStops: OsmStop[], refTag: string) { 176 | for (const osmStop of surroundOsmStops) { 177 | 178 | const code = gtfsStop.code; 179 | 180 | const platform = osmStop.platform && checkCodeForElement(osmStop.platform, refTag, code); 181 | const stopPosition = osmStop.stopPosition && checkCodeForElement(osmStop.stopPosition, refTag, code); 182 | 183 | if (platform || stopPosition) { 184 | return { 185 | id: gtfsStop.id, 186 | osmStop, 187 | gtfsStop, 188 | codeMatch: { 189 | platform, 190 | stopPosition 191 | } 192 | } as StopMatch 193 | } 194 | } 195 | } 196 | /** 197 | * Parse routes from osm data 198 | */ 199 | export function listRouteRelationsOSMData(osmData: OSMData) { 200 | const relations = osmData.elements 201 | .filter(e => e.type === 'relation'); 202 | 203 | return relations.filter(e => e.tags).filter(e => { 204 | return e.tags['type'] === 'route' && 205 | ROUTE_TYPES.includes(e.tags['route']); 206 | }); 207 | } 208 | 209 | export class RoutesMatch { 210 | 211 | matched: RouteMatchType[] 212 | unmatchedGtfs: RouteMatchType[] 213 | unmatchedOsm: RouteMatchType[] 214 | 215 | settings: MatchSettingsType 216 | 217 | osmRoutes: OsmRoute[] 218 | noRefRelations: OSMRelation[] 219 | 220 | constructor(settings: MatchSettingsType, gtfsData: GTFSData, osmData: OSMData, stopsMatch: StopMatchData) { 221 | 222 | this.settings = { 223 | ...settings 224 | }; 225 | 226 | this.osmRoutes = []; 227 | 228 | this.matched = []; 229 | this.unmatchedGtfs = []; 230 | this.unmatchedOsm = []; 231 | this.noRefRelations = []; 232 | 233 | this._runMatch(gtfsData, osmData, stopsMatch); 234 | } 235 | 236 | // TODO: Check for matched stops inside matched or unmatched 237 | // routes and trips 238 | 239 | // @ts-ignore stopsMatch newer read locally 240 | _runMatch(gtfsData: GTFSData, osmData: OSMData, stopsMatch: StopMatchData) { 241 | const routeRelations = listRouteRelationsOSMData(osmData); 242 | const refTag = this.settings.refTag; 243 | 244 | const ref2Rel = new Map(); 245 | 246 | routeRelations.forEach(rel => { 247 | const ref = rel.tags[refTag] || rel.tags['ref']; 248 | if (ref) { 249 | if (!ref2Rel.has(ref)) { 250 | ref2Rel.set(ref, []); 251 | } 252 | 253 | ref2Rel.get(ref).push(rel); 254 | } 255 | else { 256 | this.noRefRelations.push(rel as OSMRelation); 257 | } 258 | }); 259 | 260 | const osmRouteByRef = new Map(); 261 | for (const [ref, relations] of ref2Rel.entries()) { 262 | const osmRoute = new OsmRoute(ref, relations); 263 | osmRouteByRef.set(ref, osmRoute); 264 | this.osmRoutes.push(osmRoute); 265 | } 266 | 267 | Object.values(gtfsData.routes).forEach(gtfsRoute => { 268 | const osmRoute = osmRouteByRef.get(gtfsRoute.id); 269 | if (osmRoute) { 270 | this.matched.push({ 271 | osmRoute, 272 | gtfsRoute, 273 | }); 274 | } 275 | else { 276 | this.unmatchedGtfs.push({gtfsRoute}); 277 | } 278 | }); 279 | 280 | const matchedIds = this.matched.map(m => m.gtfsRoute!.id); 281 | for (let [ref, osmRoute] of osmRouteByRef.entries()) { 282 | if (!matchedIds.includes(ref)) { 283 | this.unmatchedOsm.push({osmRoute}); 284 | } 285 | } 286 | } 287 | } 288 | 289 | function checkCodeForElement(osmElement: OSMElement, refTag: string, code: string) { 290 | if (osmElement && osmElement.tags) { 291 | return osmElement.tags[refTag] === code; 292 | } 293 | 294 | return false; 295 | } 296 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames'; 2 | import { useCallback, useMemo, useState } from 'react'; 3 | import './App.css'; 4 | 5 | import GTFSLoad from './components/GTFSLoad'; 6 | import Map from './components/Map'; 7 | import MapMatchMarker from './components/MapMatchMarker'; 8 | import MapTrip from './components/MapTrip'; 9 | import MatchDetails from './components/MatchDetails'; 10 | import MatchList from './components/MatchList'; 11 | import MatchSettings, { matchSettingsMatch } from './components/MatchSettings'; 12 | import NewStopController from './components/NewStopController'; 13 | import OpenCurentViewInJosm from './components/OpenCurentViewInJosm'; 14 | import QeryOSM from './components/QueryOSM'; 15 | import RematchController from './components/RematchController'; 16 | import RouteMatch, { RoutesMatchFilters } from './components/RouteMatch'; 17 | import StopMoveController from './components/StopMoveController'; 18 | 19 | import { Changes } from './components/Changes'; 20 | import { TagEditor } from './components/OsmTags'; 21 | import RouteTripsEditor, { RouteTripsEditorContext } from './components/RouteTripsEditor'; 22 | import { CREATE_NEW, EditSubjectType, SET_MATCH, SET_POSITION, applyAction, doneEditCB } from './models/Editor'; 23 | import { ListFiltersType, defaultFilters, filterMatches } from './models/Filters'; 24 | import GTFSData, { GTFSTripUnion } from './models/GTFSData'; 25 | import { StopMatchesSequence } from './models/StopMatchesSequence'; 26 | import { RoutesMatch, StopMatchData } from './services/Matcher'; 27 | import { MatchSettingsType, StopMatch } from './services/Matcher.types'; 28 | import OSMData, { TagStatistics } from './services/OSMData'; 29 | import { OSMElementTags } from './services/OSMData.types'; 30 | import { filterTagStatsByRe, findMostPopularTag } from './services/utils'; 31 | 32 | 33 | type GTFSDataCB = (data: GTFSData) => void; 34 | type setHighlightedTrip = (gtfsTrip?: GTFSTripUnion) => void; 35 | type handleSelectInTrip = (match: StopMatch, gtfsTrip: GTFSTripUnion) => void; 36 | type handleOsmDataCB = (osmData: OSMData) => void; 37 | 38 | function App() { 39 | 40 | const [activeTab, selectTab] = useState('import'); 41 | 42 | const [gtfsData, setGtfsData] = useState(); 43 | const onGtfsLoaded = useCallback(data => { 44 | setGtfsData(data); 45 | }, []); 46 | 47 | const [osmData, setOSMData] = useState(); 48 | const [matchSettings, setMatchSettings] = useState({ 49 | refTag: 'gtfs:ref', 50 | matchByName: false, 51 | matchByCodeInName: false, 52 | }); 53 | 54 | const [matchData, setMatchData] = useState(); 55 | const [gtfsTags, setGtfsTags] = useState(); 56 | 57 | const [filters, setFilters] = useState(defaultFilters); 58 | 59 | const [selectedMatch, selectMatch] = useState(); 60 | const [highlightedTrip, setHighlightedGtfsTrip] = useState(); 61 | const [highlightedMatchTrip, setHighlightedMatchTrip] = useState(); 62 | const [routesMatch, setRoutesMatch] = useState(); 63 | const [routeEditorSubj, setRouteEditorSubj] = useState({}); 64 | 65 | const [platformTemplate, setPlatformTemplate] = useState({ 66 | 'public_transport': 'platform', 67 | 'highway': 'bus_stop', 68 | }); 69 | 70 | const setHighlightedTrip = useCallback(gtfsTrip => { 71 | setHighlightedGtfsTrip(gtfsTrip); 72 | 73 | const stopMatchSequence = (matchData && gtfsTrip) ? 74 | new StopMatchesSequence(gtfsTrip, matchData) : undefined; 75 | 76 | setHighlightedMatchTrip(stopMatchSequence); 77 | }, [setHighlightedGtfsTrip, setHighlightedMatchTrip, matchData]); 78 | 79 | const handleSelectNextInTrip = useCallback((match, gtfsTrip) => { 80 | const inx = gtfsTrip.stopSequence.findIndex(stop => stop.id === match.gtfsStop!.id); 81 | if (inx >= 0) { 82 | const targetId = gtfsTrip.stopSequence[inx + 1]?.id; 83 | const targetMatch = matchData!.matchByGtfsId[targetId]; 84 | if (targetMatch) { 85 | selectMatch(targetMatch); 86 | } 87 | } 88 | 89 | }, [matchData, selectMatch]); 90 | 91 | const handleSelectPrevInTrip = useCallback((match, gtfsTrip) => { 92 | const inx = gtfsTrip.stopSequence.findIndex(stop => stop.id === match.gtfsStop!.id); 93 | if (inx >= 0) { 94 | const targetId = gtfsTrip.stopSequence[inx - 1]?.id; 95 | const targetMatch = matchData!.matchByGtfsId[targetId]; 96 | if (targetMatch) { 97 | selectMatch(targetMatch); 98 | } 99 | } 100 | 101 | }, [matchData, selectMatch]); 102 | 103 | 104 | const handleOsmData = useCallback((data) => { 105 | const tagStats = data.calculateTagStatistics(el => el.type === 'node'); 106 | const refTags = filterTagStatsByRe(tagStats, /gtfs|ref/); 107 | 108 | setGtfsTags(refTags); 109 | 110 | findMostPopularTag(refTags, 50, tagKey => { 111 | setMatchSettings({ 112 | ...matchSettings, 113 | refTag: tagKey 114 | }); 115 | }); 116 | 117 | setOSMData(data); 118 | 119 | }, [setGtfsTags, setOSMData, matchSettings, setMatchSettings]); 120 | 121 | const matchDone = matchSettingsMatch(matchSettings, matchData?.settings); 122 | 123 | const dataBBOX = gtfsData && gtfsData.bbox; 124 | 125 | const runMatch = useCallback(() => { 126 | if (!gtfsData || !osmData) { 127 | return; 128 | } 129 | 130 | const match = new StopMatchData(matchSettings, gtfsData, osmData); 131 | 132 | setRoutesMatch(new RoutesMatch(matchSettings, gtfsData, osmData, match)); 133 | 134 | setMatchData(match); 135 | selectTab('stops'); 136 | }, [matchSettings, gtfsData, osmData, setMatchData]); 137 | 138 | const filteredMatches = useMemo(() => { 139 | return matchData && filterMatches(matchData, filters); 140 | }, [matchData, matchData?.matched, filters]); 141 | 142 | // Editor state 143 | const [editSubj, setEditSubj] = useState(); 144 | const doneEdit = useCallback(editData => { 145 | if (!editSubj || !osmData || !matchData) { 146 | return; 147 | } 148 | 149 | const {action, match, role} = editSubj; 150 | 151 | const actionDef = { 152 | action, 153 | match, 154 | role, 155 | options: { platformTemplate }, 156 | data: editData 157 | }; 158 | 159 | // @ts-ignore 160 | const { success, matchDataUpdated } = applyAction(actionDef, osmData, matchData); 161 | 162 | if (success && matchDataUpdated) { 163 | setMatchData(matchData); 164 | selectMatch({...match}); 165 | } 166 | 167 | setEditSubj(undefined); 168 | }, [editSubj, setEditSubj]); 169 | 170 | const possibleOSMRefTags = Object.entries(gtfsTags || {}) 171 | .map(([key, val]) =>
{key} ({val} objects)
); 172 | 173 | const doNew = editSubj?.action === CREATE_NEW; 174 | const doMove = editSubj?.action === SET_POSITION; 175 | const doRematch = editSubj?.action === SET_MATCH; 176 | 177 | const hideMarkers = doNew || doRematch; 178 | 179 | const matchMarkers = filteredMatches?.map(match => 180 | 181 | ); 182 | 183 | const ceneter = (selectedMatch?.osmStop || selectedMatch?.gtfsStop)?.getLonLat(); 184 | 185 | return <> 186 |
187 |
188 | 189 |
190 | {selectTab('import')}} key={'import'}> 193 | Import 194 | 195 | {selectTab('stops')}} key={'stops'}> 198 | Stops 199 | 200 | {selectTab('routes')}} key={'routes'}> 203 | Routes 204 | 205 | {selectTab('trips')}} key={'trips'}> 208 | Trips 209 | 210 | {selectTab('changes')}} key={'changes'}> 213 | Changes 214 | 215 |
216 | 217 |
218 |
219 | 220 | 221 | 222 | 223 | { gtfsTags &&

Posible GTFS stop code tags

{possibleOSMRefTags}
} 224 | { gtfsData && } 225 | 226 | { osmData && } 227 | 228 |

Template tags for platform

229 | { setPlatformTemplate(t); }} /> 232 |
233 |
234 | 235 |
236 | {selectedMatch && osmData && gtfsData && matchData && 237 | 243 | } 244 | 245 | {matchData && gtfsData && } 252 |
253 | 254 |
255 | 256 |
257 |

Matched

258 | { matchData && routesMatch?.matched?.map(r => 259 | ) 264 | } 265 | 266 |

Unmatched GTFS

267 | { matchData && routesMatch?.unmatchedGtfs?.map(r => 268 | ) 272 | } 273 | 274 |

Unmatched OSM

275 | { matchData && routesMatch?.unmatchedOsm?.map(r => 276 | ) 280 | } 281 | 282 |

OSM Routes without ref

283 | { routesMatch?.noRefRelations?.map(r =>
{ r.tags.name }
) } 284 |
285 |
286 | 287 |
288 | 289 |
290 | 291 |
292 |
293 | 294 |
295 |
296 |
297 | 298 |
299 | 300 |
301 | { 302 | 303 | 304 | {highlightedMatchTrip && 305 | } 306 | 307 | { !hideMarkers && matchMarkers } 308 | 309 | { doRematch && matchData && } 311 | { doNew && } 313 | { doMove && } 315 | } 316 |
317 |
318 | ; 319 | } 320 | 321 | export default App; --------------------------------------------------------------------------------