├── .dockerignore ├── bun.lockb ├── public ├── favicon.ico ├── pwa-64x64.png ├── pwa-192x192.png ├── pwa-512x512.png ├── maskable-icon-512x512.png ├── apple-touch-icon-180x180.png ├── icons │ └── rapidkl │ │ ├── icon_line_ampang.png │ │ ├── icon_line_kajang-01.png │ │ ├── icon_line_kelana-jaya.png │ │ ├── icon_line_kl-monorail.png │ │ ├── icon_connecting-station.png │ │ ├── icon_interchange-station.png │ │ ├── icon_line_putrajaya-01.png │ │ └── icon_line_sri-petaling.png └── logo.svg ├── .gitignore ├── react-router.config.ts ├── functions └── [[path]].ts ├── app ├── routes.ts ├── routes │ ├── settings.tsx │ ├── donate.tsx │ ├── about.tsx │ ├── home.tsx │ ├── line.tsx │ ├── search.tsx │ └── route.$index.tsx ├── components │ ├── TransitionWrapper.tsx │ ├── Navigation.tsx │ ├── Button.tsx │ └── LocationAutocomplete.tsx ├── lib │ ├── rapidklIcons.ts │ ├── motis.ts │ └── line.ts ├── entry.server.tsx ├── app.css └── root.tsx ├── load-context.ts ├── Dockerfile ├── tsconfig.json ├── .github └── FUNDING.yml ├── LICENSE ├── package.json ├── vite.config.ts └── README.md /.dockerignore: -------------------------------------------------------------------------------- 1 | .react-router 2 | build 3 | node_modules 4 | README.md -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/bun.lockb -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/pwa-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/pwa-64x64.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | -------------------------------------------------------------------------------- /public/pwa-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/pwa-192x192.png -------------------------------------------------------------------------------- /public/pwa-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/pwa-512x512.png -------------------------------------------------------------------------------- /public/maskable-icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/maskable-icon-512x512.png -------------------------------------------------------------------------------- /public/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_ampang.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_ampang.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_kajang-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_kajang-01.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_kelana-jaya.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_kelana-jaya.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_kl-monorail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_kl-monorail.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_connecting-station.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_connecting-station.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_interchange-station.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_interchange-station.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_putrajaya-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_putrajaya-01.png -------------------------------------------------------------------------------- /public/icons/rapidkl/icon_line_sri-petaling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zackrsli/commute-my/HEAD/public/icons/rapidkl/icon_line_sri-petaling.png -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /functions/[[path]].ts: -------------------------------------------------------------------------------- 1 | import { createPagesFunctionHandler } from "@react-router/cloudflare"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 4 | // @ts-ignore - the server build file is generated by `remix vite:build` 5 | // eslint-disable-next-line import/no-unresolved 6 | import * as build from "../build/server"; 7 | 8 | export const onRequest = createPagesFunctionHandler({ build }); -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig, index, route } from "@react-router/dev/routes"; 2 | 3 | export default [ 4 | index("routes/home.tsx"), 5 | route("/line/:id", "routes/line.tsx"), 6 | route("/search", "routes/search.tsx"), 7 | route("/route/:index", "routes/route.$index.tsx"), 8 | route("/settings", "routes/settings.tsx"), 9 | route("/donate", "routes/donate.tsx"), 10 | route("/about", "routes/about.tsx"), 11 | ] satisfies RouteConfig; 12 | -------------------------------------------------------------------------------- /app/routes/settings.tsx: -------------------------------------------------------------------------------- 1 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 2 | 3 | export default function Settings() { 4 | return ( 5 |
6 | 7 |

Settings

8 |

commute-my/frontend v0.0.1-beta

9 |
10 |
11 | ); 12 | } -------------------------------------------------------------------------------- /load-context.ts: -------------------------------------------------------------------------------- 1 | 2 | import { type PlatformProxy } from "wrangler"; 3 | // When using `wrangler.toml` to configure bindings, 4 | // `wrangler types` will generate types for those bindings 5 | // into the global `Env` interface. 6 | // Need this empty interface so that typechecking passes 7 | // even if no `wrangler.toml` exists. 8 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 9 | interface Env {} 10 | type Cloudflare = Omit, "dispose">; 11 | declare module "@react-router/cloudflare" { 12 | interface AppLoadContext { 13 | cloudflare: Cloudflare; 14 | } 15 | } -------------------------------------------------------------------------------- /app/components/TransitionWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { motion } from "motion/react"; 2 | 3 | export function TransitionWrapper({ 4 | children, 5 | className, 6 | key, 7 | }: { 8 | children: React.ReactNode; 9 | className?: string; 10 | key?: string; 11 | }) { 12 | return ( 13 | 20 | {children} 21 | 22 | ); 23 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS development-dependencies-env 2 | COPY . /app 3 | WORKDIR /app 4 | RUN npm ci 5 | 6 | FROM node:20-alpine AS production-dependencies-env 7 | COPY ./package.json package-lock.json /app/ 8 | WORKDIR /app 9 | RUN npm ci --omit=dev 10 | 11 | FROM node:20-alpine AS build-env 12 | COPY . /app/ 13 | COPY --from=development-dependencies-env /app/node_modules /app/node_modules 14 | WORKDIR /app 15 | RUN npm run build 16 | 17 | FROM node:20-alpine 18 | COPY ./package.json package-lock.json /app/ 19 | COPY --from=production-dependencies-env /app/node_modules /app/node_modules 20 | COPY --from=build-env /app/build /app/build 21 | WORKDIR /app 22 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "**/*", 4 | "**/.server/**/*", 5 | "**/.client/**/*", 6 | ".react-router/types/**/*" 7 | ], 8 | "compilerOptions": { 9 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 10 | "types": ["@react-router/cloudflare", "vite/client"], 11 | "target": "ES2022", 12 | "module": "ES2022", 13 | "moduleResolution": "bundler", 14 | "jsx": "react-jsx", 15 | "rootDirs": [".", "./.react-router/types"], 16 | "baseUrl": ".", 17 | "paths": { 18 | "~/*": ["./app/*"] 19 | }, 20 | "esModuleInterop": true, 21 | "verbatimModuleSyntax": true, 22 | "noEmit": true, 23 | "resolveJsonModule": true, 24 | "skipLibCheck": true, 25 | "strict": true 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [zackptr] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: zackptr # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Zackry Rosli 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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "commute-my", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "react-router build", 7 | "dev": "react-router dev", 8 | "start": "react-router-serve ./build/server/index.js", 9 | "typecheck": "react-router typegen && tsc", 10 | "generate-pwa-assets": "pwa-assets-generator --preset minimal public/logo.svg" 11 | }, 12 | "dependencies": { 13 | "@motis-project/motis-client": "^2.7.6", 14 | "@react-router/cloudflare": "^7.1.1", 15 | "@react-router/serve": "^7.1.1", 16 | "@tanstack/react-query": "^5.90.12", 17 | "class-variance-authority": "^0.7.1", 18 | "isbot": "^5.1.17", 19 | "lucide-react": "^0.473.0", 20 | "motion": "^11.18.1", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "react-router": "^7.1.1", 24 | "tailwind-merge": "^3.0.1" 25 | }, 26 | "devDependencies": { 27 | "@react-router/dev": "^7.1.1", 28 | "@types/node": "^20", 29 | "@types/react": "^19.0.1", 30 | "@types/react-dom": "^19.0.1", 31 | "@vite-pwa/assets-generator": "^0.2.6", 32 | "autoprefixer": "^10.4.20", 33 | "postcss": "^8.4.49", 34 | "tailwindcss": "^4.0.8", 35 | "typescript": "^5.7.2", 36 | "vite": "^5.4.11", 37 | "vite-plugin-pwa": "^0.21.1", 38 | "vite-tsconfig-paths": "^5.1.4", 39 | "wrangler": "^3.102.0", 40 | "@tailwindcss/postcss": "^4.0.8", 41 | "@tailwindcss/vite": "^4.0.8" 42 | } 43 | } -------------------------------------------------------------------------------- /app/lib/rapidklIcons.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * RapidKL Line Icon Mapping 3 | * Icons from https://myrapid.com.my 4 | */ 5 | 6 | export const RAPIDKL_LINE_ICONS: Record = { 7 | "AG": "/icons/rapidkl/icon_line_ampang.png", 8 | "SP": "/icons/rapidkl/icon_line_sri-petaling.png", 9 | "KJ": "/icons/rapidkl/icon_line_kelana-jaya.png", 10 | "MR": "/icons/rapidkl/icon_line_kl-monorail.png", 11 | "KG": "/icons/rapidkl/icon_line_kajang-01.png", 12 | "PY": "/icons/rapidkl/icon_line_putrajaya-01.png", 13 | }; 14 | 15 | export const RAPIDKL_STATION_ICONS = { 16 | interchange: "/icons/rapidkl/icon_interchange-station.png", 17 | connecting: "/icons/rapidkl/icon_connecting-station.png", 18 | }; 19 | 20 | // RapidKL official line colors (extracted from their branding) 21 | export const RAPIDKL_LINE_COLORS: Record = { 22 | "AG": "#FF8E10", // Ampang Line - Orange 23 | "SP": "#8D0C06", // Sri Petaling Line - Dark Red 24 | "KJ": "#ED0F4C", // Kelana Jaya Line - Magenta/Red 25 | "MR": "#81BC00", // KL Monorail - Green 26 | "KG": "#008640", // Kajang Line - Dark Green 27 | "PY": "#FBCD20", // Putrajaya Line - Yellow 28 | }; 29 | 30 | export function getLineIconUrl(lineId: string): string { 31 | return RAPIDKL_LINE_ICONS[lineId] || RAPIDKL_LINE_ICONS["AG"]; 32 | } 33 | 34 | export function getLineColor(lineId: string): string { 35 | return RAPIDKL_LINE_COLORS[lineId] || RAPIDKL_LINE_COLORS["AG"]; 36 | } 37 | 38 | -------------------------------------------------------------------------------- /app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { AppLoadContext, EntryContext } from "react-router"; 2 | import { ServerRouter } from "react-router"; 3 | import { isbot } from "isbot"; 4 | import { renderToReadableStream } from "react-dom/server"; 5 | 6 | export default async function handleRequest( 7 | request: Request, 8 | responseStatusCode: number, 9 | responseHeaders: Headers, 10 | routerContext: EntryContext, 11 | _loadContext: AppLoadContext 12 | ) { 13 | let shellRendered = false; 14 | const userAgent = request.headers.get("user-agent"); 15 | 16 | const body = await renderToReadableStream( 17 | , 18 | { 19 | onError(error: unknown) { 20 | responseStatusCode = 500; 21 | // Log streaming rendering errors from inside the shell. Don't log 22 | // errors encountered during initial shell rendering since they'll 23 | // reject and get logged in handleDocumentRequest. 24 | if (shellRendered) { 25 | console.error(error); 26 | } 27 | }, 28 | } 29 | ); 30 | shellRendered = true; 31 | 32 | // Ensure requests from bots and SPA Mode renders wait for all content to load before responding 33 | // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation 34 | if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) { 35 | await body.allReady; 36 | } 37 | 38 | responseHeaders.set("Content-Type", "text/html"); 39 | return new Response(body, { 40 | headers: responseHeaders, 41 | status: responseStatusCode, 42 | }); 43 | } -------------------------------------------------------------------------------- /app/app.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | 3 | html, 4 | body { 5 | @apply font-sans bg-white text-black dark:bg-dark-950 dark:text-white appearance-none; 6 | 7 | @media (prefers-color-scheme: dark) { 8 | color-scheme: dark; 9 | } 10 | } 11 | 12 | html { 13 | scroll-behavior: smooth; 14 | } 15 | 16 | @theme { 17 | --font-sans: "Open Sans", sans-serif; 18 | 19 | --color-steel-blue-50: #f2f7fc; 20 | --color-steel-blue-100: #e1edf8; 21 | --color-steel-blue-200: #c9e0f4; 22 | --color-steel-blue-300: #a4cdec; 23 | --color-steel-blue-400: #79b2e1; 24 | --color-steel-blue-500: #5995d8; 25 | --color-steel-blue-600: #457ccb; 26 | --color-steel-blue-700: #3b68ba; 27 | --color-steel-blue-800: #355698; 28 | --color-steel-blue-900: #2f4979; 29 | --color-steel-blue-950: #212e4a; 30 | 31 | --color-dark-950: #0E0E11; 32 | --color-dark-900: #18181B; 33 | --color-dark-800: #27272A; 34 | 35 | /* LRT Ampang */ 36 | --color-tangerine-500: #F5911F; 37 | --color-tangerine-900: #835018; 38 | 39 | /* LRT Sri Petaling */ 40 | --color-crimson-500: #8D0C06; 41 | --color-crimson-900: #510E0D; 42 | 43 | /* LRT Kelana Jaya */ 44 | --color-magenta-500: #ED0F4C; 45 | --color-magenta-900: #74102C; 46 | 47 | /* Monorail KL */ 48 | --color-chartreuse-500: #81BC00; 49 | --color-chartreuse-900: #517309; 50 | 51 | /* MRT Kajang */ 52 | --color-jade-500: #008640; 53 | --color-jade-900: #08532D; 54 | 55 | /* MRT Putrajaya */ 56 | --color-saffron-500: #FBCD20; 57 | --color-saffron-900: #746118; 58 | } 59 | 60 | @utility neomorphism-shadow-* { 61 | box-shadow: inset 0px 1px 0px 0px rgba(255, 255, 255, 0.35), 0px 3px 2px 0px --value(--color-*); 62 | } -------------------------------------------------------------------------------- /app/routes/donate.tsx: -------------------------------------------------------------------------------- 1 | import { LucideCoffee, LucideGithub, LucideWallet } from "lucide-react"; 2 | import { Link } from "react-router"; 3 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 4 | 5 | export default function Donate() { 6 | return ( 7 |
8 | 9 |

Donate

10 |

11 | Love using this public transport journey planner? Support our work to keep it running smoothly and growing! Your contributions directly fund maintenance, new features, and up-to-date transit information. Every donation, regardless of size, helps sustain this open-source project and improves public transportation accessibility for all users. Thanks for considering a contribution! 12 |

13 |
14 | 15 | 16 | GitHub Sponsor 17 | 18 | 19 | 20 | Ko-fi 21 | 22 |

23 | 24 | Ethereum 25 | 0x57858A202589D33d47D3322C26380a2142388E64 26 |

27 |
28 |
29 |
30 | ); 31 | } -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import viteTailwind from "@tailwindcss/vite"; 3 | import postcssTailwind from "@tailwindcss/postcss"; 4 | import { defineConfig } from "vite"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | import { VitePWA as vitePwa } from "vite-plugin-pwa"; 7 | import { cloudflareDevProxy } from "@react-router/dev/vite/cloudflare"; 8 | 9 | export default defineConfig({ 10 | css: { 11 | postcss: { 12 | plugins: [postcssTailwind], 13 | }, 14 | }, 15 | plugins: [ 16 | viteTailwind(), 17 | cloudflareDevProxy({ 18 | getLoadContext({ context }) { 19 | return { cloudflare: context.cloudflare }; 20 | } 21 | }), 22 | reactRouter(), 23 | tsconfigPaths(), 24 | vitePwa({ 25 | registerType: "autoUpdate", 26 | includeAssets: ["favicon.ico", "apple-touch-icon.png", "mask-icon.svg"], 27 | manifest: { 28 | name: "Commute", 29 | start_url: "/", 30 | short_name: "Commute", 31 | theme_color: "#000000", 32 | display: "standalone", 33 | icons: [ 34 | { 35 | src: "pwa-64x64.png", 36 | sizes: "64x64", 37 | type: "image/png", 38 | }, 39 | { 40 | src: "pwa-192x192.png", 41 | sizes: "192x192", 42 | type: "image/png", 43 | }, 44 | { 45 | src: "pwa-512x512.png", 46 | sizes: "512x512", 47 | type: "image/png", 48 | purpose: "any" 49 | }, 50 | { 51 | src: "maskable-icon-512x512.png", 52 | sizes: "512x512", 53 | type: "image/png", 54 | purpose: "maskable", 55 | } 56 | ], 57 | }, 58 | devOptions: { 59 | enabled: true, 60 | }, 61 | }), 62 | ], 63 | }); 64 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import { LucideCog, LucideCompass, LucideHeart, LucideInfo } from "lucide-react"; 2 | import { NavLink } from "react-router"; 3 | import { twMerge } from "tailwind-merge"; 4 | 5 | const navigationTopTabs = [ 6 | { 7 | to: "/", 8 | icon: LucideCompass, 9 | }, 10 | ]; 11 | 12 | const navigationBottomTabs = [ 13 | { 14 | to: "/settings", 15 | icon: LucideCog, 16 | }, 17 | { 18 | to: "/donate", 19 | icon: LucideHeart, 20 | }, 21 | { 22 | to: "/about", 23 | icon: LucideInfo, 24 | }, 25 | ]; 26 | 27 | export function Navigation() { 28 | return ( 29 | 43 | ); 44 | } -------------------------------------------------------------------------------- /app/routes/about.tsx: -------------------------------------------------------------------------------- 1 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 2 | import type { Route } from "./+types/about"; 3 | import { Link } from "react-router"; 4 | 5 | export function meta({}: Route.MetaArgs) { 6 | return [ 7 | { title: "Commute" }, 8 | { description: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 9 | { property: "og:title", content: "Commute" }, 10 | { property: "og:description", content: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 11 | ]; 12 | } 13 | 14 | export default function About() { 15 | return ( 16 |
17 | 18 |

About

19 |

Making Klang Valley public transport easier for everyone – locals & tourists alike.

20 |

21 | The Best Way to Plan Your Trip 22 |

23 |

24 | The project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists. No ads, no trackers, no paywalls — just reliable and seamless functionality wherever and whenever you need it. 25 |

26 |

27 | Motivation 28 |

29 |

30 | Commute was developed for the public good, prioritising user protection from ads and malware found in alternative solutions. We believe software should be open, accessible, and secure. 31 |

32 |

33 | Open 34 |

35 |

36 | We stay closely connected with our community, collaborating to make Commute even more valuable. Explore our source code and contribute on GitHub — we greatly appreciate your feedback and support! 37 |

38 |
39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to React Router! 2 | 3 | A modern, production-ready template for building full-stack React applications using React Router. 4 | 5 | [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default) 6 | 7 | ## Features 8 | 9 | - 🚀 Server-side rendering 10 | - ⚡️ Hot Module Replacement (HMR) 11 | - 📦 Asset bundling and optimization 12 | - 🔄 Data loading and mutations 13 | - 🔒 TypeScript by default 14 | - 🎉 TailwindCSS for styling 15 | - 📖 [React Router docs](https://reactrouter.com/) 16 | 17 | ## Getting Started 18 | 19 | ### Installation 20 | 21 | Install the dependencies: 22 | 23 | ```bash 24 | npm install 25 | ``` 26 | 27 | ### Development 28 | 29 | Start the development server with HMR: 30 | 31 | ```bash 32 | npm run dev 33 | ``` 34 | 35 | Your application will be available at `http://localhost:5173`. 36 | 37 | ## Building for Production 38 | 39 | Create a production build: 40 | 41 | ```bash 42 | npm run build 43 | ``` 44 | 45 | ## Deployment 46 | 47 | ### Docker Deployment 48 | 49 | This template includes three Dockerfiles optimized for different package managers: 50 | 51 | - `Dockerfile` - for npm 52 | - `Dockerfile.pnpm` - for pnpm 53 | - `Dockerfile.bun` - for bun 54 | 55 | To build and run using Docker: 56 | 57 | ```bash 58 | # For npm 59 | docker build -t my-app . 60 | 61 | # For pnpm 62 | docker build -f Dockerfile.pnpm -t my-app . 63 | 64 | # For bun 65 | docker build -f Dockerfile.bun -t my-app . 66 | 67 | # Run the container 68 | docker run -p 3000:3000 my-app 69 | ``` 70 | 71 | The containerized application can be deployed to any platform that supports Docker, including: 72 | 73 | - AWS ECS 74 | - Google Cloud Run 75 | - Azure Container Apps 76 | - Digital Ocean App Platform 77 | - Fly.io 78 | - Railway 79 | 80 | ### DIY Deployment 81 | 82 | If you're familiar with deploying Node applications, the built-in app server is production-ready. 83 | 84 | Make sure to deploy the output of `npm run build` 85 | 86 | ``` 87 | ├── package.json 88 | ├── package-lock.json (or pnpm-lock.yaml, or bun.lockb) 89 | ├── build/ 90 | │ ├── client/ # Static assets 91 | │ └── server/ # Server-side code 92 | ``` 93 | 94 | ## Styling 95 | 96 | This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer. 97 | 98 | --- 99 | 100 | Built with ❤️ using React Router. 101 | -------------------------------------------------------------------------------- /app/lib/motis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * MOTIS API Client using @motis-project/motis-client 3 | * Documentation: https://transitous.org/api/ 4 | */ 5 | 6 | import { geocode, plan, type Match, type Place, type PlanResponse, type GeocodeResponse } from '@motis-project/motis-client'; 7 | 8 | const MOTIS_API_BASE = 'https://api.transitous.org'; 9 | 10 | export interface Location { 11 | lat: number; 12 | lng: number; 13 | name?: string; 14 | id?: string; // Station ID (e.g., "PY05") - will be prefixed with "my-rail-kl_" when used in API 15 | } 16 | 17 | /** 18 | * Search for routes between two locations using MOTIS plan API 19 | * Uses Place format: "lat,lng" or stop ID 20 | */ 21 | export async function searchRoutes( 22 | from: Location, 23 | to: Location, 24 | startTime?: Date 25 | ): Promise { 26 | const fromPlace = from.id ? `my-rail-kl_${from.id}` : `${from.lat},${from.lng}`; 27 | const toPlace = to.id ? `my-rail-kl_${to.id}` : `${to.lat},${to.lng}`; 28 | 29 | const query = { 30 | fromPlace, 31 | toPlace, 32 | arriveBy: false, 33 | detailedTransfers: false, 34 | transitModes: "WALK,BUS,RAIL", 35 | fastestDirectFactor: 1.5, 36 | joinInterlinedLegs:false, 37 | maxMatchingDistance:250, 38 | ...(startTime && { time: startTime.toISOString() }), 39 | }; 40 | 41 | try { 42 | const response = await plan({ 43 | baseUrl: MOTIS_API_BASE, 44 | query: query as unknown as Parameters[0]['query'], 45 | }); 46 | 47 | if (response.error) { 48 | throw new Error(`MOTIS API error: ${JSON.stringify(response.error)}`); 49 | } 50 | 51 | return response.data as PlanResponse; 52 | } catch (error) { 53 | if (error instanceof Error) { 54 | throw error; 55 | } 56 | throw new Error('Failed to fetch routes from MOTIS API'); 57 | } 58 | } 59 | 60 | /** 61 | * Geocode a location name to coordinates using MOTIS geocode API 62 | */ 63 | export async function geocodeLocation(query: string): Promise { 64 | if (!query || query.trim().length < 2) { 65 | return []; 66 | } 67 | 68 | try { 69 | const response = await geocode({ 70 | baseUrl: MOTIS_API_BASE, 71 | query: { 72 | text: query.trim(), 73 | }, 74 | }); 75 | 76 | if (response.error) { 77 | console.error('Geocoding error:', response.error); 78 | return []; 79 | } 80 | 81 | return (response.data || []) as Match[]; 82 | } catch (error) { 83 | console.error('Geocoding error:', error); 84 | return []; 85 | } 86 | } 87 | 88 | export type { Match, Place, PlanResponse }; 89 | 90 | -------------------------------------------------------------------------------- /app/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, type ButtonHTMLAttributes, type PropsWithChildren } from "react"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | // TODO: Make this more flexible. 5 | type ButtonVariant = "primary" | "LRT_AG" | "LRT_SP" | "MR_MR" | "LRT_KJ" | "MRT_KG" | "MRT_PY" | string; 6 | 7 | interface ButtonContextValue { 8 | variant: ButtonVariant; 9 | } 10 | 11 | const ButtonContext = createContext(null); 12 | 13 | interface ButtonRootProps extends ButtonHTMLAttributes { 14 | variant?: ButtonVariant; 15 | } 16 | 17 | const ButtonRoot: React.FC = ({ children, variant = "primary", className, ...props }) => { 18 | return ( 19 | 20 | 36 | 37 | ); 38 | }; 39 | 40 | interface ButtonIconProps { 41 | icon: React.ReactElement; 42 | className?: string; 43 | } 44 | 45 | const ButtonIcon: React.FC = ({ icon, className }) => { 46 | const context = useContext(ButtonContext); 47 | if (!context) { 48 | throw new Error("Button.Icon must be used within Button.Root"); 49 | } 50 | 51 | return ( 52 | 53 | {icon} 54 | 55 | ); 56 | }; 57 | 58 | type ButtonTextProps = PropsWithChildren<{ 59 | className?: string; 60 | }> 61 | 62 | const ButtonText: React.FC = ({ children, className = '' }) => { 63 | const context = useContext(ButtonContext); 64 | if (!context) throw new Error('Button.Text must be used within Button.Root'); 65 | 66 | return {children}; 67 | }; 68 | 69 | export const Button = { 70 | Root: ButtonRoot, 71 | Icon: ButtonIcon, 72 | Text: ButtonText, 73 | }; -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | isRouteErrorResponse, 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | } from "react-router"; 9 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 10 | 11 | import type { Route } from "./+types/root"; 12 | import stylesheet from "./app.css?url"; 13 | import { Navigation } from "~/components/Navigation"; 14 | 15 | const queryClient = new QueryClient({ 16 | defaultOptions: { 17 | queries: { 18 | staleTime: 60 * 1000, 19 | refetchOnWindowFocus: false, 20 | }, 21 | }, 22 | }); 23 | 24 | export const links: Route.LinksFunction = () => [ 25 | { rel: "preconnect", href: "https://fonts.googleapis.com" }, 26 | { 27 | rel: "preconnect", 28 | href: "https://fonts.gstatic.com", 29 | crossOrigin: "anonymous", 30 | }, 31 | { 32 | rel: "stylesheet", 33 | href: "https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap", 34 | }, 35 | { rel: "stylesheet", href: stylesheet }, 36 | { rel: "icon", href: "/favicon.ico" }, 37 | { rel: "apple-touch-icon", href: "/apple-touch-icon-180x180.png", sizes: "180x180" }, 38 | { rel: "mask-icon", href: "/mask-icon.svg", sizes: "#000000" }, 39 | { rel: "manifest", href: "/manifest.webmanifest" }, 40 | ]; 41 | 42 | export function Layout({ children }: { children: React.ReactNode }) { 43 | return ( 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | {children} 58 | 59 | 60 | 61 | 62 | ); 63 | } 64 | 65 | export default function App() { 66 | return ( 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 74 | let message = "Oops!"; 75 | let details = "An unexpected error occurred."; 76 | let stack: string | undefined; 77 | 78 | if (isRouteErrorResponse(error)) { 79 | message = error.status === 404 ? "404" : "Error"; 80 | details = 81 | error.status === 404 82 | ? "The requested page could not be found." 83 | : error.statusText || details; 84 | } else if (import.meta.env.DEV && error && error instanceof Error) { 85 | details = error.message; 86 | stack = error.stack; 87 | } 88 | 89 | return ( 90 |
91 |

{message}

92 |

{details}

93 | {stack && ( 94 |
 95 |                     {stack}
 96 |                 
97 | )} 98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /app/routes/home.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 3 | import type { Route } from "./+types/home"; 4 | import { lines } from "~/lib/line"; 5 | import { Link, useNavigate } from "react-router"; 6 | import { LucideArrowUpDown, LucideCircleDot, LucideMapPin } from "lucide-react"; 7 | import { Button } from "~/components/Button"; 8 | import { LocationAutocomplete } from "~/components/LocationAutocomplete"; 9 | import type { Location } from "~/lib/motis"; 10 | import { getLineIconUrl } from "~/lib/rapidklIcons"; 11 | 12 | export function meta({}: Route.MetaArgs) { 13 | return [ 14 | { title: "Commute" }, 15 | { description: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 16 | { property: "og:title", content: "Commute" }, 17 | { property: "og:description", content: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 18 | ]; 19 | } 20 | 21 | export default function Home() { 22 | const navigate = useNavigate(); 23 | const [origin, setOrigin] = useState(""); 24 | const [destination, setDestination] = useState(""); 25 | const [originCoords, setOriginCoords] = useState(null); 26 | const [destinationCoords, setDestinationCoords] = useState(null); 27 | 28 | const handleSwap = () => { 29 | const temp = origin; 30 | setOrigin(destination); 31 | setDestination(temp); 32 | const tempCoords = originCoords; 33 | setOriginCoords(destinationCoords); 34 | setDestinationCoords(tempCoords); 35 | }; 36 | 37 | const handleSubmit = (e: React.FormEvent) => { 38 | e.preventDefault(); 39 | 40 | if (!originCoords) { 41 | alert('Please select an origin location'); 42 | return; 43 | } 44 | 45 | if (!destinationCoords) { 46 | alert('Please select a destination location'); 47 | return; 48 | } 49 | 50 | const params = new URLSearchParams({ 51 | fromLat: originCoords.lat.toString(), 52 | fromLng: originCoords.lng.toString(), 53 | fromName: originCoords.name || '', 54 | toLat: destinationCoords.lat.toString(), 55 | toLng: destinationCoords.lng.toString(), 56 | toName: destinationCoords.name || '', 57 | }); 58 | 59 | if (originCoords.id) { 60 | params.set('fromId', originCoords.id); 61 | } 62 | if (destinationCoords.id) { 63 | params.set('toId', destinationCoords.id); 64 | } 65 | 66 | navigate(`/search?${params.toString()}`); 67 | }; 68 | 69 | return ( 70 |
71 | 72 |
73 |

74 | Commute 75 |

76 |

77 | Making Klang Valley public transport easier for everyone – locals & tourists alike. 78 |

79 |
80 |
81 |
82 |

Plan Your Journey

83 |

Find the best route across RapidKL lines.

84 |
85 |
86 |
87 | { 90 | setOrigin(value); 91 | }} 92 | onSelect={(location) => { 93 | setOriginCoords(location); 94 | setOrigin(location.name); 95 | }} 96 | placeholder="Origin" 97 | icon={} 98 | /> 99 |
100 |
101 | { 104 | setDestination(value); 105 | }} 106 | onSelect={(location) => { 107 | setDestinationCoords(location); 108 | setDestination(location.name); 109 | }} 110 | placeholder="Destination" 111 | icon={} 112 | /> 113 |
114 | 122 |
123 | 124 | Search Route 125 | 126 |

This feature is still in beta. Please report any issues you encounter in the GitHub repository.

127 |
128 |
129 |
130 |

Or... Browse Lines

131 |

View all RapidKL train lines and their stations.

132 |
133 | {lines.map((line) => ( 134 | 135 | 136 | {`${line.name} 141 | {line.type} {line.name} 142 | 143 | 144 | ))} 145 |
146 |
147 |
148 |
149 |

150 | 🇲🇾 151 | Built by Malaysian, for Malaysians 152 |

153 |
154 |
155 |
156 | ); 157 | } 158 | -------------------------------------------------------------------------------- /app/components/LocationAutocomplete.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from "react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { geocodeLocation, type Match } from "~/lib/motis"; 4 | import { lines, type Station, getLineByStation } from "~/lib/line"; 5 | import { getLineIconUrl, getLineColor } from "~/lib/rapidklIcons"; 6 | import { LucideMapPin, LucideTrain, LucideLoader2 } from "lucide-react"; 7 | import { twMerge } from "tailwind-merge"; 8 | 9 | interface LocationAutocompleteProps { 10 | value: string; 11 | onChange: (value: string) => void; 12 | onSelect: (location: { lat: number; lng: number; name: string; id?: string }) => void; 13 | placeholder?: string; 14 | icon?: React.ReactNode; 15 | className?: string; 16 | loading?: boolean; 17 | } 18 | 19 | function findMatchingStation(match: Match): Station | null { 20 | const allStations = lines.flatMap(line => line.stations); 21 | 22 | const threshold = 0.001; 23 | let station = allStations.find( 24 | s => 25 | Math.abs(s.lat - match.lat) < threshold && 26 | Math.abs(s.lng - match.lon) < threshold 27 | ); 28 | 29 | if (station) return station; 30 | 31 | station = allStations.find( 32 | s => s.name.toLowerCase() === match.name.toLowerCase() 33 | ); 34 | 35 | return station || null; 36 | } 37 | 38 | export function LocationAutocomplete({ 39 | value, 40 | onChange, 41 | onSelect, 42 | placeholder, 43 | icon, 44 | className, 45 | loading = false, 46 | }: LocationAutocompleteProps) { 47 | const [isOpen, setIsOpen] = useState(false); 48 | const [focusedIndex, setFocusedIndex] = useState(-1); 49 | const [debouncedValue, setDebouncedValue] = useState(value); 50 | const inputRef = useRef(null); 51 | const dropdownRef = useRef(null); 52 | 53 | useEffect(() => { 54 | const timer = setTimeout(() => { 55 | setDebouncedValue(value); 56 | }, 300); 57 | 58 | return () => { 59 | clearTimeout(timer); 60 | }; 61 | }, [value]); 62 | 63 | const { data: rawSuggestions = [], isLoading } = useQuery({ 64 | queryKey: ["geocode", debouncedValue], 65 | queryFn: () => geocodeLocation(debouncedValue), 66 | enabled: debouncedValue.length >= 2 && isOpen && !loading, 67 | staleTime: 5 * 60 * 1000, // 5 minutes 68 | }); 69 | 70 | const suggestions = (() => { 71 | const stationMap = new Map(); 72 | const nonStationMatches: Match[] = []; 73 | 74 | for (const match of rawSuggestions) { 75 | const station = findMatchingStation(match); 76 | if (station) { 77 | const existing = stationMap.get(station.id); 78 | if (!existing || (match.type === "STOP" && existing.type !== "STOP")) { 79 | stationMap.set(station.id, match); 80 | } 81 | } else { 82 | nonStationMatches.push(match); 83 | } 84 | } 85 | 86 | const deduplicated = [...stationMap.values(), ...nonStationMatches]; 87 | 88 | return deduplicated.sort((a, b) => { 89 | const aIsStation = findMatchingStation(a) !== null; 90 | const bIsStation = findMatchingStation(b) !== null; 91 | const aIsTransitStop = a.type === "STOP"; 92 | const bIsTransitStop = b.type === "STOP"; 93 | 94 | if (aIsStation && bIsStation) { 95 | if (aIsTransitStop && !bIsTransitStop) return -1; 96 | if (!aIsTransitStop && bIsTransitStop) return 1; 97 | return 0; 98 | } 99 | if (aIsStation && !bIsStation) return -1; 100 | if (!aIsStation && bIsStation) return 1; 101 | 102 | if (aIsTransitStop && bIsTransitStop) return 0; 103 | if (aIsTransitStop && !bIsTransitStop) return -1; 104 | if (!aIsTransitStop && bIsTransitStop) return 1; 105 | 106 | return 0; 107 | }); 108 | })(); 109 | 110 | useEffect(() => { 111 | function handleClickOutside(event: MouseEvent) { 112 | if ( 113 | dropdownRef.current && 114 | !dropdownRef.current.contains(event.target as Node) && 115 | inputRef.current && 116 | !inputRef.current.contains(event.target as Node) 117 | ) { 118 | setIsOpen(false); 119 | setFocusedIndex(-1); 120 | } 121 | } 122 | 123 | document.addEventListener("mousedown", handleClickOutside); 124 | return () => document.removeEventListener("mousedown", handleClickOutside); 125 | }, []); 126 | 127 | const handleInputChange = (e: React.ChangeEvent) => { 128 | const newValue = e.target.value; 129 | onChange(newValue); 130 | setIsOpen(true); 131 | setFocusedIndex(-1); 132 | }; 133 | 134 | const handleSelect = (match: Match) => { 135 | onChange(match.name); 136 | const station = findMatchingStation(match); 137 | onSelect({ 138 | lat: match.lat, 139 | lng: match.lon, 140 | name: match.name, 141 | id: station?.id, 142 | }); 143 | setIsOpen(false); 144 | setFocusedIndex(-1); 145 | }; 146 | 147 | const handleKeyDown = (e: React.KeyboardEvent) => { 148 | if (!isOpen || suggestions.length === 0) return; 149 | 150 | switch (e.key) { 151 | case "ArrowDown": 152 | e.preventDefault(); 153 | setFocusedIndex((prev) => 154 | prev < suggestions.length - 1 ? prev + 1 : prev 155 | ); 156 | break; 157 | case "ArrowUp": 158 | e.preventDefault(); 159 | setFocusedIndex((prev) => (prev > 0 ? prev - 1 : -1)); 160 | break; 161 | case "Enter": 162 | e.preventDefault(); 163 | if (focusedIndex >= 0 && focusedIndex < suggestions.length) { 164 | handleSelect(suggestions[focusedIndex]); 165 | } 166 | break; 167 | case "Escape": 168 | setIsOpen(false); 169 | setFocusedIndex(-1); 170 | break; 171 | } 172 | }; 173 | 174 | const handleFocus = () => { 175 | if (value.length >= 2) { 176 | setIsOpen(true); 177 | } 178 | }; 179 | 180 | return ( 181 |
182 |
183 | {(icon || loading) && ( 184 |
185 | {loading === false && isLoading === true ? ( 186 | 187 | ) : ( 188 | icon 189 | )} 190 |
191 | )} 192 | 207 |
208 | 209 | {isOpen && suggestions.length > 0 && ( 210 |
214 | {suggestions.map((match, index) => { 215 | const station = findMatchingStation(match); 216 | const line = station ? getLineByStation(station.id) : null; 217 | const isFocused = index === focusedIndex; 218 | 219 | return ( 220 | 285 | ); 286 | })} 287 |
288 | )} 289 |
290 | ); 291 | } 292 | 293 | -------------------------------------------------------------------------------- /app/routes/line.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/home"; 2 | import { getLineById, getLineByStation, getStation } from "~/lib/line"; 3 | import { Link, useParams } from "react-router"; 4 | import { ShoppingBag, Building, TreesIcon as Tree, ArrowLeftFromLine, MapPin, Clock, MoonStar } from "lucide-react"; 5 | import { useNavigate } from "react-router"; 6 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 7 | import { getLineIconUrl, getLineColor, RAPIDKL_STATION_ICONS } from "~/lib/rapidklIcons"; 8 | 9 | const icons = { 10 | mall: ShoppingBag, 11 | park: Tree, 12 | default: Building, 13 | }; 14 | 15 | function getMosqueGoogleMapsUrl(name: string, lat: number, lng: number): string { 16 | return `https://www.google.com/maps/search/?api=1&query=${lat},${lng}&query_place_id=${encodeURIComponent(name)}`; 17 | } 18 | 19 | function getNearbyIcon(place: string): React.ElementType { 20 | if (place.toLowerCase().includes('mall') || place.toLowerCase().includes('shopping')) return icons.mall; 21 | if (place.toLowerCase().includes('park') || place.toLowerCase().includes('nature')) return icons.park; 22 | 23 | return icons.default; 24 | } 25 | 26 | export function meta({}: Route.MetaArgs) { 27 | return [ 28 | { title: "Commute" }, 29 | { description: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 30 | { property: "og:title", content: "Commute" }, 31 | { property: "og:description", content: "A project aims to make public transportation in the Klang Valley more accessible to everyone, including tourists." }, 32 | ]; 33 | } 34 | 35 | export default function Line() { 36 | let { id } = useParams(); 37 | const line = getLineById(id!); 38 | const navigate = useNavigate(); 39 | 40 | return ( 41 |
42 | 43 | 47 | {line ? ( 48 |
49 | {line.stations.map((station, index) => ( 50 |
51 |
52 |
56 | {`${line.name} 61 | 64 | {station.id} 65 | 66 |
67 | {index < line.stations.length - 1 && ( 68 |
76 | )} 77 |
78 |
79 |
80 |

81 | {station.name} 82 |

83 |
84 |
85 | {station.nearby && ( 86 |
87 |

88 | 89 | Nearby Highlights 90 |

91 |
92 | {station.nearby.map((place, ix) => { 93 | const Icon = getNearbyIcon(place); 94 | 95 | return ( 96 |
97 | 98 | {place} 99 |
100 | ) 101 | })} 102 |
103 |
104 | )} 105 | {station.mosques && ( 106 |
107 |

108 | 109 | Nearby Mosques 110 |

111 | 135 |
136 | )} 137 | {station.interchangeStations && ( 138 |
139 |

140 | Interchange 145 | Interchange Stations 146 |

147 |
148 | {station.interchangeStations.map((intStationId) => { 149 | const intStation = getStation(intStationId); 150 | const intLine = getLineByStation(intStationId); 151 | 152 | return intStation && intLine && ( 153 | 159 | {`${intLine.name} 164 | {intLine.type} {intStation.name} {intStation.id} 165 | 166 | ); 167 | })} 168 |
169 |
170 | )} 171 | {station.connectingStations && ( 172 |
173 |

174 | Connecting 179 | Connecting Stations 180 |

181 |
182 | {station.connectingStations.map((connStationId) => { 183 | const connStation = getStation(connStationId); 184 | const connLine = getLineByStation(connStationId); 185 | 186 | return connStation && connLine && ( 187 | 193 | {`${connLine.name} 198 | {connLine.type} {connStation.name} {connStation.id} 199 | 200 | ); 201 | })} 202 |
203 |
204 | )} 205 |
206 |
207 |
208 | ))} 209 |
210 | ): ( 211 |

Line not found.

212 | )} 213 | 214 |
215 | ); 216 | } 217 | -------------------------------------------------------------------------------- /app/routes/search.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useRef } from "react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useSearchParams, useNavigate, Link } from "react-router"; 4 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 5 | import type { Route } from "./+types/search"; 6 | import { searchRoutes, type Location, type PlanResponse } from "~/lib/motis"; 7 | import { Button } from "~/components/Button"; 8 | import { LocationAutocomplete } from "~/components/LocationAutocomplete"; 9 | import { LucideLoader2, LucideArrowLeftFromLine, LucideTrain, LucideClock, LucideCircleDot, LucideMapPin, LucideArrowRight, LucideCalendar } from "lucide-react"; 10 | 11 | export function meta({}: Route.MetaArgs) { 12 | return [ 13 | { title: "Search Results" }, 14 | ]; 15 | } 16 | 17 | export default function Search() { 18 | const [searchParams, setSearchParams] = useSearchParams(); 19 | const navigate = useNavigate(); 20 | 21 | const originLat = parseFloat(searchParams.get("fromLat") || ""); 22 | const originLng = parseFloat(searchParams.get("fromLng") || ""); 23 | const originName = searchParams.get("fromName") || ""; 24 | const originId = searchParams.get("fromId") || undefined; 25 | const destLat = parseFloat(searchParams.get("toLat") || ""); 26 | const destLng = parseFloat(searchParams.get("toLng") || ""); 27 | const destName = searchParams.get("toName") || ""; 28 | const destId = searchParams.get("toId") || undefined; 29 | 30 | const [originValue, setOriginValue] = useState(originName); 31 | const [destValue, setDestValue] = useState(destName); 32 | 33 | const departureDateParam = searchParams.get("departureDate"); 34 | const departureTimeParam = searchParams.get("departureTime"); 35 | 36 | const getDefaultDate = () => { 37 | const now = new Date(); 38 | const year = now.getFullYear(); 39 | const month = String(now.getMonth() + 1).padStart(2, '0'); 40 | const day = String(now.getDate()).padStart(2, '0'); 41 | return `${year}-${month}-${day}`; 42 | }; 43 | 44 | const getDefaultTime = () => { 45 | const now = new Date(); 46 | const hours = String(now.getHours()).padStart(2, '0'); 47 | const minutes = String(now.getMinutes()).padStart(2, '0'); 48 | return `${hours}:${minutes}`; 49 | }; 50 | 51 | const [departureDate, setDepartureDate] = useState(departureDateParam || getDefaultDate()); 52 | const [departureTime, setDepartureTime] = useState(departureTimeParam || getDefaultTime()); 53 | 54 | const dateDebounceRef = useRef(null); 55 | const timeDebounceRef = useRef(null); 56 | 57 | useEffect(() => { 58 | setOriginValue(originName); 59 | }, [originName]); 60 | 61 | useEffect(() => { 62 | setDestValue(destName); 63 | }, [destName]); 64 | 65 | useEffect(() => { 66 | if (departureDateParam) { 67 | setDepartureDate(departureDateParam); 68 | } 69 | if (departureTimeParam) { 70 | setDepartureTime(departureTimeParam); 71 | } 72 | }, [departureDateParam, departureTimeParam]); 73 | 74 | useEffect(() => { 75 | return () => { 76 | if (dateDebounceRef.current) { 77 | clearTimeout(dateDebounceRef.current); 78 | } 79 | if (timeDebounceRef.current) { 80 | clearTimeout(timeDebounceRef.current); 81 | } 82 | }; 83 | }, []); 84 | 85 | const origin: Location | null = 86 | !isNaN(originLat) && !isNaN(originLng) 87 | ? { lat: originLat, lng: originLng, name: originName, id: originId } 88 | : null; 89 | 90 | const destination: Location | null = 91 | !isNaN(destLat) && !isNaN(destLng) 92 | ? { lat: destLat, lng: destLng, name: destName, id: destId } 93 | : null; 94 | 95 | const handleOriginSelect = (location: Location) => { 96 | const params = new URLSearchParams(searchParams); 97 | params.set("fromLat", location.lat.toString()); 98 | params.set("fromLng", location.lng.toString()); 99 | params.set("fromName", location.name || ""); 100 | if (location.id) { 101 | params.set("fromId", location.id); 102 | } else { 103 | params.delete("fromId"); 104 | } 105 | setSearchParams(params); 106 | setOriginValue(location.name || ""); 107 | }; 108 | 109 | const handleDestSelect = (location: Location) => { 110 | const params = new URLSearchParams(searchParams); 111 | params.set("toLat", location.lat.toString()); 112 | params.set("toLng", location.lng.toString()); 113 | params.set("toName", location.name || ""); 114 | if (location.id) { 115 | params.set("toId", location.id); 116 | } else { 117 | params.delete("toId"); 118 | } 119 | setSearchParams(params); 120 | setDestValue(location.name || ""); 121 | }; 122 | 123 | const handleDepartureDateChange = (date: string) => { 124 | setDepartureDate(date); 125 | 126 | if (dateDebounceRef.current) { 127 | clearTimeout(dateDebounceRef.current); 128 | } 129 | 130 | dateDebounceRef.current = setTimeout(() => { 131 | const params = new URLSearchParams(searchParams); 132 | params.set("departureDate", date); 133 | setSearchParams(params); 134 | }, 500); 135 | }; 136 | 137 | const handleDepartureTimeChange = (time: string) => { 138 | setDepartureTime(time); 139 | 140 | if (timeDebounceRef.current) { 141 | clearTimeout(timeDebounceRef.current); 142 | } 143 | 144 | timeDebounceRef.current = setTimeout(() => { 145 | const params = new URLSearchParams(searchParams); 146 | params.set("departureTime", time); 147 | setSearchParams(params); 148 | }, 500); 149 | }; 150 | 151 | const departureDateTime = departureDate && departureTime 152 | ? (() => { 153 | const [year, month, day] = departureDate.split('-').map(Number); 154 | const [hours, minutes] = departureTime.split(':').map(Number); 155 | return new Date(year, month - 1, day, hours, minutes); 156 | })() 157 | : undefined; 158 | 159 | const { data, isLoading, error } = useQuery({ 160 | queryKey: ['route-search', origin, destination, departureDate, departureTime], 161 | queryFn: () => { 162 | if (!origin || !destination) { 163 | throw new Error('Origin and destination are required'); 164 | } 165 | return searchRoutes(origin, destination, departureDateTime); 166 | }, 167 | enabled: !!origin && !!destination, 168 | retry: 1, 169 | }); 170 | 171 | const formatTime = (timeString: string) => { 172 | const date = new Date(timeString); 173 | return date.toLocaleTimeString('en-US', { 174 | hour: '2-digit', 175 | minute: '2-digit', 176 | hour12: true 177 | }); 178 | }; 179 | 180 | const formatDuration = (seconds: number) => { 181 | const hours = Math.floor(seconds / 3600); 182 | const minutes = Math.floor((seconds % 3600) / 60); 183 | if (hours > 0) { 184 | return `${hours}h ${minutes}m`; 185 | } 186 | return `${minutes}m`; 187 | }; 188 | 189 | if (!origin || !destination) { 190 | return ( 191 |
192 | 193 |
194 |

Invalid search parameters

195 | navigate("/")}> 196 | Go Back Home 197 | 198 |
199 |
200 |
201 | ); 202 | } 203 | 204 | return ( 205 |
206 | 207 | 214 | 215 |
216 |

Search

217 |
218 |
219 |
220 | } 226 | loading={isLoading} 227 | /> 228 |
229 |
230 | } 236 | loading={isLoading} 237 | /> 238 |
239 |
240 |
241 |
242 | 243 | 244 |
245 |
246 |
247 | handleDepartureDateChange(e.target.value)} 251 | className="w-full bg-dark-800 border border-dark-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-steel-blue-500 focus:border-transparent" 252 | min={getDefaultDate()} 253 | /> 254 |
255 |
256 | handleDepartureTimeChange(e.target.value)} 260 | className="w-full bg-dark-800 border border-dark-700 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-steel-blue-500 focus:border-transparent" 261 | /> 262 |
263 |
264 |
265 |
266 |
267 | 268 | {isLoading && ( 269 |
270 | 271 | Searching for routes... 272 |
273 | )} 274 | 275 | {error && ( 276 |
277 |

278 | Error: {error instanceof Error ? error.message : 'Failed to search routes'} 279 |

280 |
281 | )} 282 | 283 | {data && data.itineraries && data.itineraries.length > 0 && ( 284 |
285 |

286 | We've found {data.itineraries.length} route(s) for your journey. 287 |

288 | {data.itineraries.map((itinerary, idx) => ( 289 | 294 |
295 |
296 |
297 |
298 |
299 | 300 | 301 | {formatTime(itinerary.startTime)} - {formatTime(itinerary.endTime)} 302 | 303 |
304 | 305 | {formatDuration(itinerary.duration)} 306 | 307 | {itinerary.transfers !== undefined && ( 308 | 309 | {itinerary.transfers} transfer{itinerary.transfers !== 1 ? 's' : ''} 310 | 311 | )} 312 |
313 | {itinerary.legs && itinerary.legs.length > 0 && ( 314 |
315 | {itinerary.legs 316 | .filter(leg => leg.mode && leg.mode !== 'WALK') 317 | .slice(0, 5) 318 | .map((leg, legIdx) => ( 319 |
323 | 324 | {leg.mode} 325 | {leg.routeShortName && ( 326 | 327 | {leg.routeShortName} 328 | 329 | )} 330 |
331 | ))} 332 | {itinerary.legs.filter(leg => leg.mode === 'WALK').length > 0 && ( 333 | 334 | + {itinerary.legs.filter(leg => leg.mode === 'WALK').length} walk{itinerary.legs.filter(leg => leg.mode === 'WALK').length !== 1 ? 's' : ''} 335 | 336 | )} 337 |
338 | )} 339 |
340 |
341 |
342 | 343 | ))} 344 |
345 | )} 346 | 347 | {data && data.itineraries && data.itineraries.length === 0 && ( 348 |
349 |

No routes found. Please try different locations.

350 |
351 | )} 352 |
353 |
354 | ); 355 | } 356 | 357 | -------------------------------------------------------------------------------- /app/lib/line.ts: -------------------------------------------------------------------------------- 1 | export type LineType = "LRT" | "MR" | "MRT" ; 2 | 3 | export type Mosque = { 4 | name: string; 5 | distance: string; 6 | walkingTime: string; 7 | lat: number; 8 | lng: number; 9 | }; 10 | 11 | export type Line = { 12 | id: string; 13 | type: LineType; 14 | name: string; 15 | color: string; 16 | bgColor: string; 17 | stations: Station[]; 18 | }; 19 | 20 | export type Station = { 21 | id: string; 22 | name: string; 23 | lat: number; 24 | lng: number; 25 | nearby?: string[] | undefined; 26 | connectingStations?: string[] | undefined; 27 | interchangeStations?: string[] | undefined; 28 | mosques?: Mosque[] | undefined; 29 | }; 30 | 31 | export const ampangLine: Line = { 32 | id: "AG", 33 | type: "LRT", 34 | name: "Ampang", 35 | color: "tangerine", 36 | bgColor: "bg-tangerine-500", 37 | stations: [ 38 | { id: "AG18", name: "Ampang", lat: 3.150318, lng: 101.760049, interchangeStations: ["KG22"] }, 39 | { id: "AG17", name: "Cahaya", lat: 3.140575, lng: 101.756677 }, 40 | { id: "AG16", name: "Cempaka", lat: 3.138324, lng: 101.752979 }, 41 | { id: "AG15", name: "Pandan Indah", lat: 3.134581, lng: 101.746509 }, 42 | { id: "AG14", name: "Pandan Jaya", lat: 3.130141, lng: 101.739122 }, 43 | { id: "AG13", name: "Maluri", lat: 3.12329, lng: 101.727283, nearby: ["Sunway Velocity Mall"] }, 44 | { id: "AG12", name: "Miharja", lat: 3.120973, lng: 101.717922, nearby: ["Viva Mall"] }, 45 | { id: "AG11", name: "Chan Sow Lin", lat: 3.128105, lng: 101.715637, nearby: ["The Metro Mall"], interchangeStations: ["PY24", "SP11"] }, 46 | { id: "AG10", name: "Pudu", lat: 3.134879, lng: 101.711957, interchangeStations: ["SP10"] }, 47 | { id: "AG9", name: "Hang Tuah", lat: 3.140012, lng: 101.705984, nearby: ["Berjaya Times Square", "The Mitsui Shopping Park LaLaport Bukit Bintang City Centre"], interchangeStations: ["SP9", "MR4"], mosques: [ 48 | { name: "Masjid Al Bukhari", distance: "270m", walkingTime: "4 min", lat: 3.1391978, lng: 101.7046318 } 49 | ] }, 50 | { id: "AG8", name: "Plaza Rakyat", lat: 3.144049, lng: 101.702105, interchangeStations: ["SP8", "KG17"] }, 51 | { id: "AG7", name: "Masjid Jamek", lat: 3.14927, lng: 101.696377, interchangeStations: ["KJ13", "SP7"] }, 52 | { id: "AG6", name: "Bandaraya", lat: 3.155567, lng: 101.694485, nearby: ["SOGO Kuala Lumpur"], interchangeStations: ["SP6"] }, 53 | { id: "AG5", name: "Sultan Ismail", lat: 3.161245, lng: 101.694109, interchangeStations: ["SP5"], connectingStations: ["MR9"] }, 54 | { id: "AG4", name: "PWTC", lat: 3.166333, lng: 101.693586, nearby: ["Sunway Putra Mall"], interchangeStations: ["SP4"] }, 55 | { id: "AG3", name: "Titiwangsa", lat: 3.173497, lng: 101.695367, interchangeStations: ["PY17", "SP3", "MR11"] }, 56 | { id: "AG2", name: "Sentul", lat: 3.178484, lng: 101.695542, interchangeStations: ["SP2"] }, 57 | { id: "AG1", name: "Sentul Timur", lat: 3.185897, lng: 101.695217, interchangeStations: ["SP1"] }, 58 | ], 59 | }; 60 | 61 | export const sriPetalingLine: Line = { 62 | id: "SP", 63 | type: "LRT", 64 | name: "Sri Petaling", 65 | color: "crimson", 66 | bgColor: "bg-crimson-500", 67 | stations: [ 68 | { id: "SP31", name: "Putra Heights", lat: 2.996016, lng: 101.575521, interchangeStations: ["KJ37"], mosques: [ 69 | { name: "Masjid Putra Height", distance: "220m", walkingTime: "3 min", lat: 2.9975838904405965, lng: 101.57626199789122 } 70 | ] }, 71 | { id: "SP29", name: "Puchong Prima", lat: 2.999808, lng: 101.596692 }, 72 | { id: "SP28", name: "Puchong Perdana", lat: 3.007913, lng: 101.605021 }, 73 | { id: "SP27", name: "Bandar Puteri", lat: 3.017111, lng: 101.612855 }, 74 | { id: "SP26", name: "Taman Perindustrian Puchong", lat: 3.022814, lng: 101.613514 }, 75 | { id: "SP25", name: "Pusat Bandar Puchong", lat: 3.033194, lng: 101.616057, nearby: ["SetiaWalk"] }, 76 | { id: "SP24", name: "IOI Puchong Jaya", lat: 3.048101, lng: 101.62095, nearby: ["IOI Mall Puchong"] }, 77 | { id: "SP22", name: "Kinrara", lat: 3.050506, lng: 101.644294 }, 78 | { id: "SP21", name: "Alam Sutera", lat: 3.0547, lng: 101.656468 }, 79 | { id: "SP20", name: "Muhibbah", lat: 3.062229, lng: 101.662552 }, 80 | { id: "SP19", name: "Awan Besar", lat: 3.062131, lng: 101.670555 }, 81 | { id: "SP18", name: "Sri Petaling", lat: 3.061445, lng: 101.687074 }, 82 | { id: "SP17", name: "Bukit Jalil", lat: 3.058196, lng: 101.692125, nearby: ["Endah Parade"] }, 83 | { id: "SP16", name: "Sungai Besi", lat: 3.063842, lng: 101.708062, interchangeStations: ["PY29"], mosques: [ 84 | { name: "Masjid Jamek Sungai Besi (Ibnu Khaldun)", distance: "220m", walkingTime: "3 min", lat: 3.064344710399052, lng: 101.70925452938673 } 85 | ] }, 86 | { id: "SP15", name: "Bandar Tasik Selatan", lat: 3.076058, lng: 101.711107 }, 87 | { id: "SP14", name: "Bandar Tun Razak", lat: 3.089576, lng: 101.712466 }, 88 | { id: "SP13", name: "Salak Selatan", lat: 3.102201, lng: 101.706179 }, 89 | { id: "SP12", name: "Cheras", lat: 3.112609, lng: 101.714178 }, 90 | { id: "SP11", name: "Chan Sow Lin", lat: 3.128105, lng: 101.715637, nearby: ["The Metro Mall"], interchangeStations: ["PY24", "AG11"] }, 91 | { id: "SP10", name: "Pudu", lat: 3.134879, lng: 101.711957, interchangeStations: ["AG10"] }, 92 | { id: "SP9", name: "Hang Tuah", lat: 3.140012, lng: 101.705984, nearby: ["Berjaya Times Square", "The Mitsui Shopping Park LaLaport Bukit Bintang City Centre"], interchangeStations: ["MR4", "AG9"], mosques: [ 93 | { name: "Masjid Al Bukhari", distance: "270m", walkingTime: "4 min", lat: 3.1391978, lng: 101.7046318 } 94 | ] }, 95 | { id: "SP8", name: "Plaza Rakyat", lat: 3.144049, lng: 101.702105, interchangeStations: ["AG8", "KG17"] }, 96 | { id: "SP7", name: "Masjid Jamek", lat: 3.14927, lng: 101.696377, interchangeStations: ["AG7", "KJ13"] }, 97 | { id: "SP6", name: "Bandaraya", lat: 3.155567, lng: 101.694485, nearby: ["SOGO Kuala Lumpur"], interchangeStations: ["AG6"] }, 98 | { id: "SP5", name: "Sultan Ismail", lat: 3.161245, lng: 101.694109, interchangeStations: ["AG5"], connectingStations: ["MR9"] }, 99 | { id: "SP4", name: "PWTC", lat: 3.166333, lng: 101.693586, nearby: ["Sunway Putra Mall"], interchangeStations: ["AG4"]}, 100 | { id: "SP3", name: "Titiwangsa", lat: 3.173497, lng: 101.695367, interchangeStations: ["PY17", "AG3", "MR11"] }, 101 | { id: "SP2", name: "Sentul", lat: 3.178484, lng: 101.695542, interchangeStations: ["AG2"] }, 102 | { id: "SP1", name: "Sentul Timur", lat: 3.185897, lng: 101.695217, interchangeStations: ["AG1"] }, 103 | ], 104 | }; 105 | 106 | export const kelanaJayaLine: Line = { 107 | id: "KJ", 108 | type: "LRT", 109 | name: "Kelana Jaya", 110 | color: "magenta", 111 | bgColor: "bg-magenta-500", 112 | stations: [ 113 | { id: "KJ37", name: "Putra Heights", lat: 2.996227, lng: 101.575462, interchangeStations: ["SP31"], mosques: [ 114 | { name: "Masjid Putra Height", distance: "220m", walkingTime: "3 min", lat: 2.9975838904405965, lng: 101.57626199789122 } 115 | ] }, 116 | { id: "KJ36", name: "Subang Alam", lat: 3.009421, lng: 101.572281 }, 117 | { id: "KJ35", name: "Alam Megah", lat: 3.023151, lng: 101.572029 }, 118 | { id: "KJ34", name: "USJ 21", lat: 3.029881, lng: 101.581711, nearby: ["Main Place Mall"], mosques: [ 119 | { name: "Masjid Al-Madaniah", distance: "590m", walkingTime: "7 min", lat: 3.0313921849430185, lng: 101.58398391263445 } 120 | ] }, 121 | { id: "KJ33", name: "Wawasan", lat: 3.035062, lng: 101.588348, nearby: ["The 19 USJ City Mall (Palazzo 19 Mall)"] }, 122 | { id: "KJ32", name: "Taipan", lat: 3.04815, lng: 101.590233, mosques: [ 123 | { name: "Masjid Al-Falah USJ 9", distance: "580m", walkingTime: "7 min", lat: 3.0440662790664836, lng: 101.58719996488976 } 124 | ] }, 125 | { id: "KJ31", name: "USJ 7", lat: 3.054956, lng: 101.592194, nearby: ["DA MEN Mall"] }, 126 | { id: "KJ30", name: "SS 18", lat: 3.067182, lng: 101.585945 }, 127 | { id: "KJ29", name: "SS 15", lat: 3.075972, lng: 101.585983, nearby: ["SS15 Courtyard"], mosques: [ 128 | { name: "Masjid Darul Ehsan Subang Jaya", distance: "670m", walkingTime: "8 min", lat: 3.0804905148779658, lng: 101.58554713022856 } 129 | ] }, 130 | { id: "KJ28", name: "Subang Jaya", lat: 3.08466, lng: 101.588127, nearby: ["NU Empire Shopping Gallery", "Subang Parade Shopping Centre", "AEON BiG Subang Jaya"] }, 131 | { id: "KJ27", name: "CGC Glenmarie", lat: 3.094732, lng: 101.590622 }, 132 | { id: "KJ26", name: "Ara Damansara", lat: 3.108643, lng: 101.586372, nearby: ["Evolve Concept Mall"] }, 133 | { id: "KJ25", name: "Lembah Subang", lat: 3.112094, lng: 101.591034 }, 134 | { id: "KJ24", name: "Kelana Jaya", lat: 3.112497, lng: 101.6043 }, 135 | { id: "KJ23", name: "Taman Bahagia", lat: 3.11079, lng: 101.612856 }, 136 | { id: "KJ22", name: "Taman Paramount", lat: 3.104716, lng: 101.623192 }, 137 | { id: "KJ21", name: "Asia Jaya", lat: 3.104343, lng: 101.637695 }, 138 | { id: "KJ20", name: "Taman Jaya", lat: 3.104086, lng: 101.645248, nearby: ["Amcorp Mall"] }, 139 | { id: "KJ19", name: "Universiti", lat: 3.114616, lng: 101.661639, nearby: ["KL Gateway Mall"], mosques: [ 140 | { name: "Masjid Ar-Rahman", distance: "450m", walkingTime: "6 min", lat: 3.1178179, lng: 101.6628105 } 141 | ] }, 142 | { id: "KJ18", name: "Kerinchi", lat: 3.115506, lng: 101.668572, mosques: [ 143 | { name: "Masjid Ar-Rahah", distance: "650m", walkingTime: "8 min", lat: 3.1137199, lng: 101.6687277 }, 144 | { name: "Masjid Menara TM", distance: "360m", walkingTime: "4 min", lat: 3.1161164, lng: 101.6656948 } 145 | ] }, 146 | { id: "KJ17", name: "Abdullah Hukum", lat: 3.118735, lng: 101.672897, nearby: ["Mid Valley Megamall", "The Gardens Mall", "KL Eco City"], mosques: [ 147 | { name: "Masjid TNB", distance: "410m", walkingTime: "5 min", lat: 3.1177874, lng: 101.6709643 } 148 | ] }, 149 | { id: "KJ16", name: "Bank Rakyat Bangsar", lat: 3.127588, lng: 101.679062 }, 150 | { id: "KJ15", name: "KL Sentral", lat: 3.13442, lng: 101.68625, nearby: ["NU Sentral"], connectingStations: ["MR1"] }, 151 | { id: "KJ14", name: "Pasar Seni", lat: 3.142439, lng: 101.69531, interchangeStations: ["PY14"], mosques: [ 152 | { name: "Masjid Negara", distance: "870m", walkingTime: "12 min", lat: 3.1419713907686377, lng: 101.69174639937577 } 153 | ] }, 154 | { id: "KJ13", name: "Masjid Jamek", lat: 3.149714, lng: 101.696815, interchangeStations: ["AG7", "SP7"], mosques: [ 155 | { name: "Masjid Jamek Sultan Abdul Samad", distance: "110m", walkingTime: "2 min", lat: 3.1489147, lng: 101.695355 } 156 | ] }, 157 | { id: "KJ12", name: "Dang Wangi", lat: 3.156942, lng: 101.701975, connectingStations: ["MR8"] }, 158 | { id: "KJ11", name: "Kampung Baru", lat: 3.161386, lng: 101.706608, mosques: [ 159 | { name: "Masjid Jamek Kg Baru", distance: "570m", walkingTime: "7 min", lat: 3.1642272, lng: 101.7012624 } 160 | ] }, 161 | { id: "KJ10", name: "KLCC", lat: 3.158935, lng: 101.713287, nearby: ["Petronas Twin Towers", "Suria KLCC", "Avenue K"], mosques: [ 162 | { name: "Masjid As-Syakirin", distance: "530m", walkingTime: "6 min", lat: 3.1572322, lng: 101.713708 } 163 | ] }, 164 | { id: "KJ9", name: "Ampang Park", lat: 3.159894, lng: 101.719017, nearby: ["The LINC KL", "Intermark Mall"], connectingStations: ["PY20"] }, 165 | { id: "KJ8", name: "Damai", lat: 3.164406, lng: 101.724489 }, 166 | { id: "KJ7", name: "Dato' Keramat", lat: 3.16509, lng: 101.73184, mosques: [ 167 | { name: "Masjid Al-Akram Datuk Keramat", distance: "340m", walkingTime: "4 min", lat: 3.1664534702473905, lng: 101.72950400592192 } 168 | ] }, 169 | { id: "KJ6", name: "Jelatek", lat: 3.167204, lng: 101.735344, nearby: ["Datum Jelatek Shopping Centre"] }, 170 | { id: "KJ5", name: "Setiawangsa", lat: 3.17576, lng: 101.73584, mosques: [ 171 | { name: "Masjid Muadz bin Jabal", distance: "510m", walkingTime: "6 min", lat: 3.1779773, lng: 101.7361406 } 172 | ] }, 173 | { id: "KJ4", name: "Sri Rampai", lat: 3.199176, lng: 101.73747, nearby: ["Wangsa Walk Mall"] }, 174 | { id: "KJ3", name: "Wangsa Maju", lat: 3.205751, lng: 101.731796, mosques: [ 175 | { name: "Masjid Usamah Bin Zaid", distance: "750m", walkingTime: "10 min", lat: 3.2029713366589996, lng: 101.73692327782841 } 176 | ] }, 177 | { id: "KJ2", name: "Taman Melati", lat: 3.219558, lng: 101.72197, nearby: ["M3 Shopping Mall"], mosques: [ 178 | { name: "Masjid Salahudin Al-Ayyubi", distance: "370m", walkingTime: "5 min", lat: 3.2225904, lng: 101.7177032 } 179 | ] }, 180 | { id: "KJ1", name: "Gombak", lat: 3.231793, lng: 101.724427 }, 181 | ], 182 | }; 183 | 184 | export const monorailKlLine: Line = { 185 | id: "MR", 186 | type: "MR", 187 | name: "Monorail KL", 188 | color: "chartreuse", 189 | bgColor: "bg-chartreuse-500", 190 | stations: [ 191 | { id: "MR1", name: "KL Sentral", lat: 3.132852, lng: 101.687817, nearby: ["NU Sentral"], connectingStations: ["KJ15"] }, 192 | { id: "MR2", name: "Tun Sambanthan", lat: 3.13132, lng: 101.69085 }, 193 | { id: "MR3", name: "Maharajalela", lat: 3.138743, lng: 101.699268, mosques: [ 194 | { name: "Masjid Al-Sultan Abdullah", distance: "190m", walkingTime: "3 min", lat: 3.1397038347130954, lng: 101.69941427658394 } 195 | ] }, 196 | { id: "MR4", name: "Hang Tuah", lat: 3.140511, lng: 101.706029, nearby: ["Berjaya Times Square", "The Mitsui Shopping Park LaLaport Bukit Bintang City Centre"], interchangeStations: ["AG9", "SP9"] }, 197 | { id: "MR5", name: "Imbi", lat: 3.14283, lng: 101.70945, nearby: ["Berjaya Times Square"] }, 198 | { id: "MR6", name: "Bukit Bintang", lat: 3.146022, lng: 101.7115, connectingStations: ["KG18A"] }, 199 | { id: "MR7", name: "Raja Chulan", lat: 3.150878, lng: 101.710432 }, 200 | { id: "MR8", name: "Bukit Nanas", lat: 3.156214, lng: 101.704809, connectingStations: ["KJ12"] }, 201 | { id: "MR9", name: "Medan Tuanku", lat: 3.15935, lng: 101.69888, nearby: ["Quill City Mall Kuala Lumpur"], connectingStations: ["AG5", "SP5"] }, 202 | { id: "MR10", name: "Chow Kit", lat: 3.167358, lng: 101.698379 }, 203 | { id: "MR11", name: "Titiwangsa", lat: 3.173192, lng: 101.696022, interchangeStations: ["AG3", "SP3", "PY17"] }, 204 | ], 205 | }; 206 | 207 | export const kajangLine: Line = { 208 | id: "KG", 209 | type: "MRT", 210 | name: "Kajang", 211 | color: "jade", 212 | bgColor: "bg-jade-500", 213 | stations: [ 214 | { 215 | id: "KG04", 216 | name: "Kwasa Damansara", 217 | lat: 3.176146, 218 | lng: 101.572052, 219 | interchangeStations: ["PY01"], 220 | }, 221 | { id: "KG05", name: "Kwasa Sentral", lat: 3.170112, lng: 101.564651 }, 222 | { id: "KG06", name: "Kota Damansara", lat: 3.150134, lng: 101.57869 }, 223 | { 224 | id: "KG07", 225 | name: "Surian", 226 | lat: 3.14948, 227 | lng: 101.593925, 228 | nearby: ["IOI Mall Damansara", "Sunway Giza Mall"], 229 | }, 230 | { 231 | id: "KG08", 232 | name: "Mutiara Damansara", 233 | lat: 3.155301, 234 | lng: 101.609077, 235 | nearby: ["The Curve", "IPC Shopping Centre", "IKEA Damansara"], 236 | }, 237 | { 238 | id: "KG09", 239 | name: "Bandar Utama", 240 | lat: 3.14671, 241 | lng: 101.618599, 242 | nearby: ["1 Utama Shopping Centre"], 243 | }, 244 | { 245 | id: "KG10", 246 | name: "Taman Tun Dr Ismail", 247 | lat: 3.13613, 248 | lng: 101.630539, 249 | nearby: ["Glo Damansara Shopping Mall"], 250 | }, 251 | { id: "KG12", name: "Phileo Damansara", lat: 3.129864, lng: 101.642471 }, 252 | { 253 | id: "KG13", 254 | name: "Pusat Bandar Damansara", 255 | lat: 3.143444, 256 | lng: 101.662857, 257 | nearby: ["Pavilion Damansara Heights"], 258 | }, 259 | { id: "KG14", name: "Semantan", lat: 3.150977, lng: 101.665497 }, 260 | { 261 | id: "KG15", 262 | name: "Muzium Negara", 263 | lat: 3.137317, 264 | lng: 101.687336, 265 | nearby: ["NU Sentral"], 266 | connectingStations: ["KJ15"], 267 | }, 268 | { 269 | id: "KG16", 270 | name: "Pasar Seni", 271 | lat: 3.142293265, 272 | lng: 101.6955642, 273 | interchangeStations: ["KJ14"], 274 | mosques: [ 275 | { name: "Masjid Negara", distance: "870m", walkingTime: "12 min", lat: 3.1419713907686377, lng: 101.69174639937577 } 276 | ], 277 | }, 278 | { 279 | id: "KG17", 280 | name: "Merdeka", 281 | lat: 3.141969, 282 | lng: 101.70205, 283 | interchangeStations: ["AG8", "SP8"], 284 | mosques: [ 285 | { 286 | name: "Masjid Al-Sultan Abdullah", 287 | distance: "690m", 288 | walkingTime: "9 min", 289 | lat: 3.1395985, 290 | lng: 101.6994226, 291 | }, 292 | ], 293 | }, 294 | { 295 | id: "KG18A", 296 | name: "Bukit Bintang", 297 | lat: 3.146503, 298 | lng: 101.710947, 299 | nearby: [ 300 | "Pavilion Kuala Lumpur", 301 | "Fahrenheit88", 302 | "Lot 10 Shopping Centre", 303 | "Low Yat Plaza", 304 | "Sungei Wang Plaza", 305 | "The Starhill", 306 | ], 307 | connectingStations: ["MR6"], 308 | }, 309 | { 310 | id: "KG20", 311 | name: "Tun Razak Exchange", 312 | lat: 3.142403, 313 | lng: 101.720156, 314 | nearby: ["The Exchange TRX", "Apple The Exchange TRX"], 315 | interchangeStations: ["PY23"], 316 | }, 317 | { 318 | id: "KG21", 319 | name: "Cochrane", 320 | lat: 3.132829, 321 | lng: 101.722962, 322 | nearby: ["MyTOWN Shopping Centre", "IKEA Cheras", "Sunway Velocity Mall"], 323 | mosques: [ 324 | { 325 | name: "Masjid Jamek Alam Shah", 326 | distance: "590m", 327 | walkingTime: "7 min", 328 | lat: 3.1350761, 329 | lng: 101.7180825, 330 | }, 331 | ], 332 | }, 333 | { 334 | id: "KG22", 335 | name: "Maluri", 336 | lat: 3.123623, 337 | lng: 101.727809, 338 | nearby: ["Sunway Velocity Mall"], 339 | interchangeStations: ["AG13"], 340 | }, 341 | { id: "KG23", name: "Taman Pertama", lat: 3.112547, lng: 101.729371 }, 342 | { id: "KG24", name: "Taman Midah", lat: 3.104505, lng: 101.732186 }, 343 | { 344 | id: "KG25", 345 | name: "Taman Mutiara", 346 | lat: 3.090989, 347 | lng: 101.740453, 348 | nearby: ["EkoCheras Mall", "Cheras LeisureMall"], 349 | }, 350 | { 351 | id: "KG26", 352 | name: "Taman Connaught", 353 | lat: 3.079172, 354 | lng: 101.74522, 355 | nearby: ["Cheras Sentral Mall"], 356 | }, 357 | { id: "KG27", name: "Taman Suntex", lat: 3.071578, lng: 101.763552 }, 358 | { id: "KG28", name: "Sri Raya", lat: 3.062273, lng: 101.772899 }, 359 | { 360 | id: "KG29", 361 | name: "Bandar Tun Hussien Onn", 362 | lat: 3.048223, 363 | lng: 101.775109, 364 | }, 365 | { id: "KG30", name: "Batu 11 Cheras", lat: 3.041339, lng: 101.773383 }, 366 | { id: "KG31", name: "Bukit Dukung", lat: 3.026413, lng: 101.771072 }, 367 | { id: "KG33", name: "Sungai Jernih", lat: 3.000948, lng: 101.783857 }, 368 | { 369 | id: "KG34", 370 | name: "Stadium Kajang", 371 | lat: 2.994514, 372 | lng: 101.786338, 373 | nearby: ["Plaza Metro Kajang"], 374 | }, 375 | { id: "KG35", name: "Kajang", lat: 2.982778, lng: 101.790278 }, 376 | ], 377 | }; 378 | 379 | export const putrajayaLine: Line = { 380 | id: "PY", 381 | type: "MRT", 382 | name: "Putrajaya", 383 | color: "saffron", 384 | bgColor: "bg-saffron-500", 385 | stations: [ 386 | { id: "PY01", name: "Kwasa Damansara", lat: 3.1763324, lng: 101.5721456, interchangeStations: ["KG04"] }, 387 | { id: "PY03", name: "Kampung Selamat", lat: 3.197266, lng: 101.578499, nearby: ["Anytime Fitness SqWhere"] }, 388 | { id: "PY04", name: "Sungai Buloh", lat: 3.206429, lng: 101.581779 }, 389 | { id: "PY05", name: "Damansara Damai", lat: 3.199892, lng: 101.592623 }, 390 | { id: "PY06", name: "Sri Damansara Barat", lat: 3.198197, lng: 101.608302, nearby: ["Anytime Fitness Sri Damansara"] }, 391 | { id: "PY07", name: "Sri Damansara Sentral", lat: 3.198815, lng: 101.621396 }, 392 | { id: "PY08", name: "Sri Damansara Timur", lat: 3.207832, lng: 101.628716, nearby: ["Kompleks Desa Kepong"] }, 393 | { id: "PY09", name: "Metro Prima", lat: 3.214438, lng: 101.639402 }, 394 | { id: "PY10", name: "Kepong Baru", lat: 3.211663, lng: 101.648193 }, 395 | { id: "PY11", name: "Jinjang", lat: 3.209544, lng: 101.655829 }, 396 | { id: "PY12", name: "Sri Delima", lat: 3.207108, lng: 101.665749, nearby: ["Brem Mall Shopping Complex"] }, 397 | { id: "PY13", name: "Kampung Batu", lat: 3.205521, lng: 101.675473 }, 398 | { id: "PY14", name: "Kentomen", lat: 3.19563, lng: 101.6797 }, 399 | { id: "PY15", name: "Jalan Ipoh", lat: 3.189319, lng: 101.681145 }, 400 | { id: "PY16", name: "Sentul Barat", lat: 3.179369, lng: 101.684742 }, 401 | { id: "PY17", name: "Titiwangsa", lat: 3.17408, lng: 101.69581, interchangeStations: ["AG3", "SP3", "MR11"] }, 402 | { id: "PY18", name: "Hospital Kuala Lumpur", lat: 3.17405, lng: 101.70239, nearby: ["Hospital Kuala Lumpur", "KPJ Tawakkal KL Specialist Hospital"] }, 403 | { id: "PY19", name: "Raja Uda", lat: 3.16794, lng: 101.71017 }, 404 | { id: "PY20", name: "Ampang Park", lat: 3.16225, lng: 101.71781, connectingStations: ["KJ9"] }, 405 | { id: "PY21", name: "Persiaran KLCC", lat: 3.15712, lng: 101.71834 }, 406 | { id: "PY22", name: "Conlay", lat: 3.15145, lng: 101.71801 }, 407 | { id: "PY23", name: "Tun Razak Exchange", lat: 3.14289, lng: 101.72034, nearby: ["The Exchange TRX", "Apple The Exchange TRX"], interchangeStations: ["KG20"] }, 408 | { id: "PY24", name: "Chan Sow Lin", lat: 3.12839, lng: 101.71663, nearby: ["The Metro Mall"], interchangeStations: ["AG11", "SP11"] }, 409 | { id: "PY27", name: "Kuchai", lat: 3.089546, lng: 101.694124 }, 410 | { id: "PY28", name: "Taman Naga Emas", lat: 3.077688, lng: 101.699867 }, 411 | { id: "PY29", name: "Sungai Besi", lat: 3.063737, lng: 101.7084, interchangeStations: ["SP16"], mosques: [ 412 | { name: "Masjid Jamek Sungai Besi (Ibnu Khaldun)", distance: "220m", walkingTime: "3 min", lat: 3.064344710399052, lng: 101.70925452938673 } 413 | ] }, 414 | { id: "PY31", name: "Serdang Raya Utara", lat: 3.041674, lng: 101.704928, mosques: [ 415 | { name: "Masjid Al-Islah Serdang Raya", distance: "310m", walkingTime: "4 min", lat: 3.043026344004756, lng: 101.70343677890706 } 416 | ] }, 417 | { id: "PY32", name: "Serdang Raya Selatan", lat: 3.028463, lng: 101.707514 }, 418 | { id: "PY33", name: "Serdang Jaya", lat: 3.0216, lng: 101.709 }, 419 | { id: "PY34", name: "UPM", lat: 3.008489, lng: 101.705396 }, 420 | { id: "PY36", name: "Taman Equine", lat: 2.98942, lng: 101.67244 }, 421 | { id: "PY37", name: "Putra Permai", lat: 2.98339, lng: 101.66099 }, 422 | { id: "PY38", name: "16 Sierra", lat: 2.964974, lng: 101.654812 }, 423 | { id: "PY39", name: "Cyberjaya Utara", lat: 2.95, lng: 101.6573 }, 424 | { id: "PY40", name: "Cyberjaya City Centre", lat: 2.9384, lng: 101.6659 }, 425 | { id: "PY41", name: "Putrajaya Sentral", lat: 2.9313, lng: 101.6715, nearby: ["Terminal Putrajaya Sentral"] }, 426 | ], 427 | }; 428 | 429 | export const lines: Line[] = [ampangLine, sriPetalingLine, kelanaJayaLine, monorailKlLine, kajangLine, putrajayaLine]; 430 | 431 | export function getLinesByType(type: LineType): Line[] { 432 | return lines.filter((line) => line.type === type); 433 | } 434 | 435 | export function getLineById(id: string): Line | undefined { 436 | return lines.find((line) => line.id === id); 437 | } 438 | 439 | export function getStation(id: string): Station | undefined { 440 | return lines.flatMap(line => line.stations).find(station => station.id === id); 441 | } 442 | 443 | export function getLineByStation(id: string): Line | undefined { 444 | return lines.find(line => line.stations.some(station => station.id === id)); 445 | } -------------------------------------------------------------------------------- /app/routes/route.$index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useQuery } from "@tanstack/react-query"; 3 | import { useSearchParams, useNavigate } from "react-router"; 4 | import { TransitionWrapper } from "~/components/TransitionWrapper"; 5 | import type { Route } from "./+types/route.$index"; 6 | import { searchRoutes, type Location, type PlanResponse } from "~/lib/motis"; 7 | import { LucideArrowLeftFromLine, LucideClock, LucideMapPin, LucideFootprints, LucideArrowRight, LucideTrain, LucideChevronRight, LucideChevronsRight, LucideArrowRightLeft, LucideChevronDown, LucideBus } from "lucide-react"; 8 | import { useParams } from "react-router"; 9 | import { getLineIconUrl, getLineColor } from "~/lib/rapidklIcons"; 10 | import { lines, type Station, getLineByStation } from "~/lib/line"; 11 | 12 | export function meta({}: Route.MetaArgs) { 13 | return [ 14 | { title: "Route Details" }, 15 | ]; 16 | } 17 | 18 | function extractStationIdFromStopId(stopId: string | undefined): string | null { 19 | if (!stopId) return null; 20 | const parts = stopId.split('_'); 21 | if (parts.length > 1) { 22 | return parts[parts.length - 1]; 23 | } 24 | return null; 25 | } 26 | 27 | function findStationByLocation( 28 | lat: number | undefined, 29 | lon: number | undefined, 30 | name: string | undefined, 31 | stopId: string | undefined 32 | ): Station | null { 33 | const allStations = lines.flatMap(line => line.stations); 34 | 35 | if (stopId) { 36 | const stationId = extractStationIdFromStopId(stopId); 37 | if (stationId) { 38 | const station = allStations.find(s => s.id === stationId); 39 | if (station) return station; 40 | } 41 | } 42 | 43 | if (lat && lon) { 44 | const threshold = 0.001; 45 | let station = allStations.find( 46 | s => 47 | Math.abs(s.lat - lat) < threshold && 48 | Math.abs(s.lng - lon) < threshold 49 | ); 50 | 51 | if (station) return station; 52 | } 53 | 54 | if (name) { 55 | const station = allStations.find( 56 | s => s.name.toLowerCase() === name.toLowerCase() 57 | ); 58 | if (station) return station; 59 | } 60 | 61 | return null; 62 | } 63 | 64 | export default function RouteDetail() { 65 | const { index } = useParams(); 66 | const [searchParams] = useSearchParams(); 67 | const navigate = useNavigate(); 68 | const [expandedStops, setExpandedStops] = useState>(new Set()); 69 | 70 | const routeIndex = parseInt(index || "0", 10); 71 | 72 | const originLat = parseFloat(searchParams.get("fromLat") || ""); 73 | const originLng = parseFloat(searchParams.get("fromLng") || ""); 74 | const originName = searchParams.get("fromName") || ""; 75 | const destLat = parseFloat(searchParams.get("toLat") || ""); 76 | const destLng = parseFloat(searchParams.get("toLng") || ""); 77 | const destName = searchParams.get("toName") || ""; 78 | 79 | const origin: Location | null = 80 | !isNaN(originLat) && !isNaN(originLng) 81 | ? { lat: originLat, lng: originLng, name: originName } 82 | : null; 83 | 84 | const destination: Location | null = 85 | !isNaN(destLat) && !isNaN(destLng) 86 | ? { lat: destLat, lng: destLng, name: destName } 87 | : null; 88 | 89 | const { data, isLoading, error } = useQuery({ 90 | queryKey: ['route-search', origin, destination], 91 | queryFn: () => { 92 | if (!origin || !destination) { 93 | throw new Error('Origin and destination are required'); 94 | } 95 | return searchRoutes(origin, destination); 96 | }, 97 | enabled: !!origin && !!destination, 98 | retry: 1, 99 | }); 100 | 101 | const formatTime = (timeString: string) => { 102 | const date = new Date(timeString); 103 | return date.toLocaleTimeString('en-US', { 104 | hour: '2-digit', 105 | minute: '2-digit', 106 | hour12: true 107 | }); 108 | }; 109 | 110 | const formatDuration = (seconds: number) => { 111 | const hours = Math.floor(seconds / 3600); 112 | const minutes = Math.floor((seconds % 3600) / 60); 113 | if (hours > 0) { 114 | return `${hours}h ${minutes}m`; 115 | } 116 | return `${minutes}m`; 117 | }; 118 | 119 | if (!origin || !destination) { 120 | return ( 121 |
122 | 123 |
124 |

Invalid route parameters

125 | 128 |
129 |
130 |
131 | ); 132 | } 133 | 134 | if (isLoading) { 135 | return ( 136 |
137 | 138 |
139 | Loading route details... 140 |
141 |
142 |
143 | ); 144 | } 145 | 146 | if (error || !data || !data.itineraries || routeIndex >= data.itineraries.length) { 147 | return ( 148 |
149 | 150 |
151 |

152 | {error ? (error instanceof Error ? error.message : 'Failed to load route') : 'Route not found'} 153 |

154 | 157 |
158 |
159 |
160 | ); 161 | } 162 | 163 | const itinerary = data.itineraries[routeIndex]; 164 | 165 | 166 | 167 | return ( 168 |
169 | 170 | 177 | 178 |
179 |

Route Details

180 |
181 |
182 | 183 | {originName || `${origin.lat}, ${origin.lng}`} 184 |
185 | 186 |
187 | 188 | {destName || `${destination.lat}, ${destination.lng}`} 189 |
190 |
191 |
192 |
193 | 194 | {formatTime(itinerary.startTime)} - {formatTime(itinerary.endTime)} 195 |
196 | 197 | {formatDuration(itinerary.duration)} 198 | 199 | {itinerary.transfers !== undefined && ( 200 | 201 | {itinerary.transfers} transfer{itinerary.transfers !== 1 ? 's' : ''} 202 | 203 | )} 204 |
205 |
206 | 207 |
208 | {(() => { 209 | const allLegs = itinerary.legs || []; 210 | const filteredLegs = allLegs.filter((leg, legIdx, allLegs) => { 211 | if (leg.mode === 'WALK' && leg.from && leg.to) { 212 | const fromStation = findStationByLocation(leg.from.lat, leg.from.lon, leg.from.name, leg.from.stopId); 213 | const toStation = findStationByLocation(leg.to.lat, leg.to.lon, leg.to.name, leg.to.stopId); 214 | 215 | const nextLeg = allLegs[legIdx + 1]; 216 | const isInterchange = nextLeg && nextLeg.mode !== 'WALK' && nextLeg.routeShortName; 217 | 218 | if (isInterchange) { 219 | return true; 220 | } 221 | 222 | if (legIdx === 0) { 223 | const coordinateThreshold = 0.001; 224 | 225 | const originStation = findStationByLocation(leg.from.lat, leg.from.lon, leg.from.name, leg.from.stopId); 226 | 227 | if (originStation) { 228 | const destinationStation = findStationByLocation(leg.to.lat, leg.to.lon, leg.to.name, leg.to.stopId); 229 | 230 | const originToStationLatDiff = Math.abs((leg.from.lat || 0) - originStation.lat); 231 | const originToStationLonDiff = Math.abs((leg.from.lon || 0) - originStation.lng); 232 | 233 | if (originToStationLatDiff < coordinateThreshold && originToStationLonDiff < coordinateThreshold) { 234 | if (destinationStation && destinationStation.id === originStation.id) { 235 | return false; 236 | } 237 | 238 | const latDiff = Math.abs((leg.from.lat || 0) - (leg.to.lat || 0)); 239 | const lonDiff = Math.abs((leg.from.lon || 0) - (leg.to.lon || 0)); 240 | 241 | if (latDiff < coordinateThreshold && lonDiff < coordinateThreshold) { 242 | return false; 243 | } 244 | } 245 | } 246 | 247 | if (nextLeg && nextLeg.from) { 248 | const latDiff = Math.abs((leg.to.lat || 0) - (nextLeg.from.lat || 0)); 249 | const lonDiff = Math.abs((leg.to.lon || 0) - (nextLeg.from.lon || 0)); 250 | 251 | if (latDiff < coordinateThreshold && lonDiff < coordinateThreshold) { 252 | return false; 253 | } 254 | } 255 | } 256 | 257 | const latDiff = Math.abs((leg.from.lat || 0) - (leg.to.lat || 0)); 258 | const lonDiff = Math.abs((leg.from.lon || 0) - (leg.to.lon || 0)); 259 | const distanceThreshold = 0.001; 260 | 261 | const walkDuration = leg.duration || 0; 262 | const isSameStation = fromStation && toStation && fromStation.id === toStation.id; 263 | const isVeryClose = latDiff < distanceThreshold && lonDiff < distanceThreshold; 264 | const isVeryShortWalk = walkDuration < 180; 265 | 266 | if ((isSameStation && isVeryShortWalk) || (isVeryClose && isVeryShortWalk)) { 267 | return false; 268 | } 269 | } 270 | return true; 271 | }); 272 | 273 | const lastOriginalLeg = allLegs[allLegs.length - 1]; 274 | const lastFilteredLeg = filteredLegs[filteredLegs.length - 1]; 275 | const lastLegWasFiltered = lastOriginalLeg && 276 | lastOriginalLeg.mode === 'WALK' && 277 | (!lastFilteredLeg || lastFilteredLeg !== lastOriginalLeg); 278 | 279 | return ( 280 | <> 281 | {filteredLegs.map((leg, idx) => { 282 | const isWalking = leg.mode === 'WALK'; 283 | const isBus = leg.mode === 'BUS'; 284 | const prevLeg = idx > 0 ? filteredLegs[idx - 1] : null; 285 | const nextLeg = idx < filteredLegs.length - 1 ? filteredLegs[idx + 1] : null; 286 | 287 | const isSameStationAsNext = nextLeg && 288 | leg.to && 289 | nextLeg.from && 290 | Math.abs((leg.to.lat || 0) - (nextLeg.from.lat || 0)) < 0.001 && 291 | Math.abs((leg.to.lon || 0) - (nextLeg.from.lon || 0)) < 0.001; 292 | 293 | const prevLegToMatchesThisFrom = prevLeg && 294 | leg.from && 295 | prevLeg.to && 296 | Math.abs((leg.from.lat || 0) - (prevLeg.to.lat || 0)) < 0.001 && 297 | Math.abs((leg.from.lon || 0) - (prevLeg.to.lon || 0)) < 0.001; 298 | 299 | const prevLegToWasHidden = prevLegToMatchesThisFrom && 300 | nextLeg && 301 | nextLeg.from && 302 | Math.abs((prevLeg.to.lat || 0) - (nextLeg.from.lat || 0)) < 0.001 && 303 | Math.abs((prevLeg.to.lon || 0) - (nextLeg.from.lon || 0)) < 0.001; 304 | 305 | const isSameStationAsPrev = prevLegToMatchesThisFrom && !prevLegToWasHidden; 306 | 307 | const isWalkingInterchange = isWalking && 308 | prevLeg !== null && 309 | nextLeg !== null && 310 | prevLeg.mode !== 'WALK' && 311 | nextLeg.mode !== 'WALK' && 312 | prevLeg.routeShortName && 313 | nextLeg.routeShortName && 314 | prevLeg.routeShortName !== nextLeg.routeShortName; 315 | 316 | const isTransitInterchange = !isWalking && 317 | prevLeg !== null && 318 | prevLeg.mode !== 'WALK' && 319 | leg.mode !== 'WALK' && 320 | prevLeg.routeShortName && 321 | leg.routeShortName && 322 | prevLeg.routeShortName !== leg.routeShortName; 323 | 324 | const isInterchange = isWalkingInterchange || isTransitInterchange; 325 | 326 | let nextLineColor = '#6B7280'; 327 | if (isWalkingInterchange && nextLeg) { 328 | const nextRouteShortName = nextLeg.routeShortName?.toUpperCase() || ''; 329 | if (nextRouteShortName.includes('AG') || nextRouteShortName.includes('AMPANG')) { 330 | nextLineColor = getLineColor('AG'); 331 | } else if (nextRouteShortName.includes('SP') || nextRouteShortName.includes('SRI PETALING')) { 332 | nextLineColor = getLineColor('SP'); 333 | } else if (nextRouteShortName.includes('KJ') || nextRouteShortName.includes('KELANA')) { 334 | nextLineColor = getLineColor('KJ'); 335 | } else if (nextRouteShortName.includes('MR') || nextRouteShortName.includes('MONORAIL')) { 336 | nextLineColor = getLineColor('MR'); 337 | } else if (nextRouteShortName.includes('KG') || nextRouteShortName.includes('KAJANG')) { 338 | nextLineColor = getLineColor('KG'); 339 | } else if (nextRouteShortName.includes('PY') || nextRouteShortName.includes('PUTRAJAYA')) { 340 | nextLineColor = getLineColor('PY'); 341 | } 342 | } 343 | 344 | const routeShortNameForIcon = isWalkingInterchange && prevLeg?.routeShortName 345 | ? prevLeg.routeShortName.toUpperCase() 346 | : leg.routeShortName?.toUpperCase() || ''; 347 | 348 | let lineIconUrl: string | null = null; 349 | let lineColor = isWalking ? '#6B7280' : isBus ? '#10b981' : '#5995d8'; 350 | 351 | if (isWalking) { 352 | // For walking interchanges, use prevLeg.to.stopId since that's the station being displayed 353 | // For regular walking, use leg.to or leg.from 354 | const transitStationStopId = isWalkingInterchange && prevLeg?.to?.stopId 355 | ? prevLeg.to.stopId 356 | : leg.to?.stopId || leg.from?.stopId; 357 | if (transitStationStopId) { 358 | const stationIdFromStopId = extractStationIdFromStopId(transitStationStopId); 359 | if (stationIdFromStopId) { 360 | const linePrefix = stationIdFromStopId.match(/^([A-Z]+)/)?.[1]; 361 | if (linePrefix && ['AG', 'SP', 'KJ', 'MR', 'KG', 'PY'].includes(linePrefix)) { 362 | lineIconUrl = getLineIconUrl(linePrefix); 363 | lineColor = getLineColor(linePrefix); 364 | } 365 | } 366 | } 367 | } 368 | 369 | if (!lineIconUrl) { 370 | if (routeShortNameForIcon.includes('AG') || routeShortNameForIcon.includes('AMPANG')) { 371 | lineIconUrl = getLineIconUrl('AG'); 372 | lineColor = getLineColor('AG'); 373 | } else if (routeShortNameForIcon.includes('SP') || routeShortNameForIcon.includes('SRI PETALING')) { 374 | lineIconUrl = getLineIconUrl('SP'); 375 | lineColor = getLineColor('SP'); 376 | } else if (routeShortNameForIcon.includes('KJ') || routeShortNameForIcon.includes('KELANA')) { 377 | lineIconUrl = getLineIconUrl('KJ'); 378 | lineColor = getLineColor('KJ'); 379 | } else if (routeShortNameForIcon.includes('MR') || routeShortNameForIcon.includes('MONORAIL')) { 380 | lineIconUrl = getLineIconUrl('MR'); 381 | lineColor = getLineColor('MR'); 382 | } else if (routeShortNameForIcon.includes('KG') || routeShortNameForIcon.includes('KAJANG')) { 383 | lineIconUrl = getLineIconUrl('KG'); 384 | lineColor = getLineColor('KG'); 385 | } else if (routeShortNameForIcon.includes('PY') || routeShortNameForIcon.includes('PUTRAJAYA')) { 386 | lineIconUrl = getLineIconUrl('PY'); 387 | lineColor = getLineColor('PY'); 388 | } 389 | } 390 | 391 | return ( 392 |
393 |
394 |
398 | {lineIconUrl ? ( 399 | {`${leg.mode 404 | ) : isWalking ? ( 405 | 406 | ) : isBus ? ( 407 | 408 | ) : ( 409 |
410 | )} 411 |
412 | {(() => { 413 | const isLastLeg = idx === filteredLegs.length - 1; 414 | const lastLegTo = isLastLeg ? leg.to : null; 415 | const lastLegMatchesDestination = isLastLeg && destination && lastLegTo && 416 | Math.abs((lastLegTo.lat || 0) - destination.lat) < 0.001 && 417 | Math.abs((lastLegTo.lon || 0) - destination.lng) < 0.001; 418 | const willShowFinalDestination = destination && ( 419 | lastLegWasFiltered || 420 | !lastLegTo || 421 | lastLegMatchesDestination || 422 | Math.abs((lastLegTo.lat || 0) - destination.lat) > 0.001 || 423 | Math.abs((lastLegTo.lon || 0) - destination.lng) > 0.001 424 | ); 425 | 426 | return idx < filteredLegs.length - 1 || (isLastLeg && willShowFinalDestination); 427 | })() && ( 428 |
436 | )} 437 |
438 |
439 | {(idx === 0 || isSameStationAsPrev || prevLegToWasHidden || isWalkingInterchange) && ( 440 |
441 |
442 |

443 | {(isWalkingInterchange && prevLeg?.to?.name) ? prevLeg.to.name : (leg.from?.name || 'Unknown')} 444 | {(() => { 445 | const stationLocation = isWalkingInterchange && prevLeg?.to 446 | ? prevLeg.to 447 | : leg.from; 448 | // For walking interchanges, use prevLeg's routeShortName since we're showing prevLeg.to station 449 | const routeShortNameForStation = isWalkingInterchange && prevLeg?.routeShortName 450 | ? prevLeg.routeShortName.toUpperCase() 451 | : leg.routeShortName?.toUpperCase() || ''; 452 | 453 | if (!stationLocation || (isWalking && !isWalkingInterchange && !stationLocation.stopId)) return null; 454 | 455 | const station = findStationByLocation(stationLocation.lat, stationLocation.lon, stationLocation.name, stationLocation.stopId); 456 | if (!station) return null; 457 | 458 | let matchedLineId: string | null = null; 459 | 460 | // Always use the stationLocation's stopId to get the correct line 461 | const stopIdToUse = stationLocation.stopId; 462 | 463 | if (stopIdToUse) { 464 | const stationIdFromStopId = extractStationIdFromStopId(stopIdToUse); 465 | if (stationIdFromStopId) { 466 | const linePrefix = stationIdFromStopId.match(/^([A-Z]+)/)?.[1]; 467 | if (linePrefix && ['AG', 'SP', 'KJ', 'MR', 'KG', 'PY'].includes(linePrefix)) { 468 | matchedLineId = linePrefix; 469 | } 470 | } 471 | } 472 | 473 | if (!matchedLineId) { 474 | if (routeShortNameForStation.includes('AG') || routeShortNameForStation.includes('AMPANG')) { 475 | matchedLineId = 'AG'; 476 | } else if (routeShortNameForStation.includes('SP') || routeShortNameForStation.includes('SRI PETALING')) { 477 | matchedLineId = 'SP'; 478 | } else if (routeShortNameForStation.includes('KJ') || routeShortNameForStation.includes('KELANA')) { 479 | matchedLineId = 'KJ'; 480 | } else if (routeShortNameForStation.includes('MR') || routeShortNameForStation.includes('MONORAIL')) { 481 | matchedLineId = 'MR'; 482 | } else if (routeShortNameForStation.includes('KG') || routeShortNameForStation.includes('KAJANG')) { 483 | matchedLineId = 'KG'; 484 | } else if (routeShortNameForStation.includes('PY') || routeShortNameForStation.includes('PUTRAJAYA')) { 485 | matchedLineId = 'PY'; 486 | } 487 | } 488 | 489 | // Find the station ID that matches the line being used 490 | const allStationIds = [station.id, ...(station.interchangeStations || [])]; 491 | const matchingStationId = matchedLineId 492 | ? allStationIds.find(id => { 493 | const stationLine = getLineByStation(id); 494 | return stationLine?.id === matchedLineId; 495 | }) || station.id 496 | : station.id; 497 | 498 | const stationLine = getLineByStation(matchingStationId); 499 | if (!stationLine) return null; 500 | 501 | return ( 502 | 506 | {matchingStationId} 507 | 508 | ); 509 | })()} 510 | {!isWalking && leg.headsign && ( 511 | 512 | {leg.headsign} 513 | 514 | )} 515 |

516 |
517 | {(() => { 518 | const timeToShow = isWalkingInterchange && prevLeg?.endTime 519 | ? prevLeg.endTime 520 | : leg.startTime; 521 | return timeToShow ? ( 522 |
523 | 524 | {formatTime(timeToShow)} 525 |
526 | ) : null; 527 | })()} 528 |
529 | )} 530 | 531 |
532 | {isWalking && ( 533 |
534 | {isWalkingInterchange && nextLeg ? ( 535 |
536 |
537 | 538 |
539 | Interchange 540 | {(() => { 541 | const nextRouteShortName = nextLeg.routeShortName?.toUpperCase() || ''; 542 | let lineName = nextLeg.to.name; 543 | if (nextRouteShortName.includes('AG') || nextRouteShortName.includes('AMPANG')) { 544 | lineName = 'LRT Ampang'; 545 | } else if (nextRouteShortName.includes('SP') || nextRouteShortName.includes('SRI PETALING')) { 546 | lineName = 'LRT Sri Petaling'; 547 | } else if (nextRouteShortName.includes('KJ') || nextRouteShortName.includes('KELANA')) { 548 | lineName = 'LRT Kelana Jaya'; 549 | } else if (nextRouteShortName.includes('MR') || nextRouteShortName.includes('MONORAIL')) { 550 | lineName = 'KL Monorail'; 551 | } else if (nextRouteShortName.includes('KG') || nextRouteShortName.includes('KAJANG')) { 552 | lineName = 'MRT Kajang'; 553 | } else if (nextRouteShortName.includes('PY') || nextRouteShortName.includes('PUTRAJAYA')) { 554 | lineName = 'MRT Putrajaya'; 555 | } 556 | return lineName ?  to {lineName} : null; 557 | })()} 558 |
559 |
560 |
561 | ) : ( 562 |
563 | 564 | Walk {formatDuration(leg.duration || 0)} 565 |
566 | )} 567 |
568 | )} 569 |
570 | 571 | {(() => { 572 | const isLastLeg = idx === filteredLegs.length - 1; 573 | const lastLegMatchesDestination = isLastLeg && destination && leg.to && 574 | Math.abs((leg.to.lat || 0) - destination.lat) < 0.001 && 575 | Math.abs((leg.to.lon || 0) - destination.lng) < 0.001; 576 | 577 | return !isSameStationAsNext && !lastLegMatchesDestination; 578 | })() && ( 579 |
580 |
581 |

582 | {leg.to?.name || 'Unknown'} 583 | {leg.to && (() => { 584 | const station = findStationByLocation(leg.to.lat, leg.to.lon, leg.to.name, leg.to.stopId); 585 | if (!station) return null; 586 | 587 | const routeShortName = leg.routeShortName?.toUpperCase() || ''; 588 | let matchedLineId: string | null = null; 589 | 590 | if (leg.to.stopId) { 591 | const stationIdFromStopId = extractStationIdFromStopId(leg.to.stopId); 592 | if (stationIdFromStopId) { 593 | const linePrefix = stationIdFromStopId.match(/^([A-Z]+)/)?.[1]; 594 | if (linePrefix && ['AG', 'SP', 'KJ', 'MR', 'KG', 'PY'].includes(linePrefix)) { 595 | matchedLineId = linePrefix; 596 | } 597 | } 598 | } 599 | 600 | if (!matchedLineId) { 601 | if (routeShortName.includes('AG') || routeShortName.includes('AMPANG')) { 602 | matchedLineId = 'AG'; 603 | } else if (routeShortName.includes('SP') || routeShortName.includes('SRI PETALING')) { 604 | matchedLineId = 'SP'; 605 | } else if (routeShortName.includes('KJ') || routeShortName.includes('KELANA')) { 606 | matchedLineId = 'KJ'; 607 | } else if (routeShortName.includes('MR') || routeShortName.includes('MONORAIL')) { 608 | matchedLineId = 'MR'; 609 | } else if (routeShortName.includes('KG') || routeShortName.includes('KAJANG')) { 610 | matchedLineId = 'KG'; 611 | } else if (routeShortName.includes('PY') || routeShortName.includes('PUTRAJAYA')) { 612 | matchedLineId = 'PY'; 613 | } 614 | } 615 | 616 | const allStationIds = [station.id, ...(station.interchangeStations || [])]; 617 | const matchingStationId = matchedLineId 618 | ? allStationIds.find(id => { 619 | const stationLine = getLineByStation(id); 620 | return stationLine?.id === matchedLineId; 621 | }) || station.id 622 | : station.id; 623 | 624 | const stationLine = getLineByStation(matchingStationId); 625 | if (!stationLine) return null; 626 | 627 | return ( 628 | 632 | {matchingStationId} 633 | 634 | ); 635 | })()} 636 |

637 |
638 | {leg.endTime && ( 639 |
640 | 641 | {formatTime(leg.endTime)} {leg.duration && !isWalking && `(${formatDuration(leg.duration)})`} 642 |
643 | )} 644 |
645 | )} 646 | 647 | {leg.intermediateStops && leg.intermediateStops.length > 0 && ( 648 |
649 |

650 | {leg.intermediateStops.length} intermediate stop{leg.intermediateStops.length !== 1 ? 's' : ''} 651 |

652 | {(expandedStops.has(idx) ? leg.intermediateStops : leg.intermediateStops.slice(0, 5)).map((stop, stopIdx) => ( 653 |
654 | • {stop.name} 655 |
656 | ))} 657 | {leg.intermediateStops.length > 5 && ( 658 | 682 | )} 683 |
684 | )} 685 |
686 |
687 | ); 688 | })} 689 | {(() => { 690 | const lastLegTo = filteredLegs.length > 0 ? filteredLegs[filteredLegs.length - 1]?.to : null; 691 | const lastLegMatchesDestination = lastLegTo && destination && 692 | Math.abs((lastLegTo.lat || 0) - destination.lat) < 0.001 && 693 | Math.abs((lastLegTo.lon || 0) - destination.lng) < 0.001; 694 | const shouldShowFinalDestination = destination && (lastLegWasFiltered || !lastLegTo || lastLegMatchesDestination || 695 | Math.abs((lastLegTo.lat || 0) - destination.lat) > 0.001 || 696 | Math.abs((lastLegTo.lon || 0) - destination.lng) > 0.001); 697 | 698 | if (!shouldShowFinalDestination) return null; 699 | 700 | const finalDestName = destination.name || (lastLegWasFiltered && lastOriginalLeg?.to?.name) || (filteredLegs.length > 0 && filteredLegs[filteredLegs.length - 1]?.to?.name) || 'Unknown'; 701 | const finalDestLat = destination.lat ?? (lastLegWasFiltered ? lastOriginalLeg?.to?.lat : undefined) ?? (filteredLegs.length > 0 ? filteredLegs[filteredLegs.length - 1]?.to?.lat : undefined); 702 | const finalDestLon = destination.lng ?? (lastLegWasFiltered ? lastOriginalLeg?.to?.lon : undefined) ?? (filteredLegs.length > 0 ? filteredLegs[filteredLegs.length - 1]?.to?.lon : undefined); 703 | 704 | const finalDestStation = findStationByLocation(finalDestLat, finalDestLon, finalDestName, undefined); 705 | const finalDestStationLine = finalDestStation ? getLineByStation(finalDestStation.id) : null; 706 | const finalDestLineIconUrl = finalDestStationLine ? getLineIconUrl(finalDestStationLine.id) : null; 707 | const finalDestLineColor = finalDestStationLine ? getLineColor(finalDestStationLine.id) : '#60A5FA'; 708 | 709 | return ( 710 |
711 |
712 |
716 | {finalDestLineIconUrl ? ( 717 | {`${finalDestStationLine?.name 722 | ) : ( 723 | 724 | )} 725 |
726 |
727 |
728 |
729 |
730 |

731 | {finalDestName} 732 | {(() => { 733 | if (!finalDestStation) return null; 734 | const stationLine = getLineByStation(finalDestStation.id); 735 | if (!stationLine) return null; 736 | 737 | return ( 738 | 742 | {finalDestStation.id} 743 | 744 | ); 745 | })()} 746 |

747 |
748 | {itinerary.endTime && ( 749 |
750 | 751 | {formatTime(itinerary.endTime)} 752 |
753 | )} 754 |
755 |
756 |
757 | ); 758 | })()} 759 | 760 | ); 761 | })()} 762 |
763 | 764 |
765 | ); 766 | } 767 | 768 | --------------------------------------------------------------------------------