Merge pull request #1 from lucasoskorep/feat/refactor-to-use-protobufs

Feat/refactor to use protobufs
This commit is contained in:
Lucas Oskorep
2026-01-20 21:54:11 -05:00
committed by GitHub
107 changed files with 9373 additions and 12868 deletions

1
.gitea/workflows/build.yml Symbolic link
View File

@@ -0,0 +1 @@
../../.github/workflows/build.yml

67
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Build and Push Docker Image
on:
push:
branches:
- main
- master
tags:
- 'v*'
pull_request:
branches:
- main
- master
workflow_dispatch:
env:
IMAGE_NAME: pi-mta-sign
jobs:
build:
runs-on: ${{ vars.RUNNER || 'ubuntu-latest' }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY || 'ghcr.io' }}
username: ${{ vars.REGISTRY_USERNAME || github.actor }}
password: ${{ secrets.REGISTRY_PASSWORD || secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ vars.REGISTRY || 'ghcr.io' }}/${{ vars.IMAGE_OWNER || github.repository_owner }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

4
.gitignore vendored
View File

@@ -125,3 +125,7 @@ venv.bak/
dmypy.jsons
.ruff_cache
# Frontend build output
static/
mta-sign-ui/out/

1
.node-version Normal file
View File

@@ -0,0 +1 @@
v25.4.0

45
docker/Dockerfile Normal file
View File

@@ -0,0 +1,45 @@
# Stage 1: Build the Next.js frontend
FROM docker.io/library/node:24-alpine AS frontend-builder
LABEL authors="lucasoskorep"
WORKDIR /build/mta-sign-ui
# Enable corepack for pnpm
RUN corepack enable && corepack prepare pnpm@10.28.1 --activate
# Copy package files first for better caching
COPY mta-sign-ui/package.json mta-sign-ui/pnpm-lock.yaml* ./
# Install dependencies (use frozen-lockfile if lock exists, otherwise generate)
RUN pnpm install
# Copy the rest of the frontend source
COPY mta-sign-ui/ ./
# Build the static export (outputs to 'out' directory)
RUN pnpm build
# Stage 2: Python backend with frontend static files
FROM ghcr.io/astral-sh/uv:python3.13-bookworm
LABEL authors="lucasoskorep"
WORKDIR /app
# Copy dependency files and install dependencies only (not the project itself)
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-dev --no-install-project
# Copy source code
COPY mta_api_client ./mta_api_client
COPY mta_sign_server ./mta_sign_server
COPY main.py stops.txt ./
# Copy the built frontend from the first stage
COPY --from=frontend-builder /build/mta-sign-ui/out ./static
# Now install the project
RUN uv sync --frozen --no-dev
EXPOSE 8000
ENTRYPOINT ["uv", "run", "python", "main.py"]

View File

@@ -1,10 +0,0 @@
{
"ACE": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace",
"BDFM": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm",
"G": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-g",
"JZ": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-jz",
"NQRW": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw",
"L": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-l",
"SIR": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-si",
"1234567": "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs"
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

77
justfile Normal file
View File

@@ -0,0 +1,77 @@
# Show available commands
default:
@just --list
# Setup Python project
init:
uv sync
# Setup frontend project
init-ui:
cd mta-sign-ui && pnpm install
# Build frontend and run FastAPI serving static files
run: build-ui
uv run python main.py
# Development mode: Next.js dev server + FastAPI backend (hot reload for both)
dev:
#!/usr/bin/env bash
set -e
echo "Starting FastAPI backend on :8000..."
uv run python main.py &
BACKEND_PID=$!
sleep 2
echo "Starting Next.js dev server on :3000..."
cd mta-sign-ui && pnpm dev &
FRONTEND_PID=$!
trap "kill $BACKEND_PID $FRONTEND_PID 2>/dev/null" EXIT
echo ""
echo "Development servers running:"
echo " Frontend: http://localhost:3000 (with hot reload)"
echo " Backend: http://localhost:8000 (API only)"
echo ""
wait
# Run only the backend (for when you want to run frontend separately)
dev-backend:
FRONTEND_ENABLE=false uv run python main.py
# Run only the frontend dev server
dev-frontend:
cd mta-sign-ui && pnpm dev
# Lint Python project with ruff
lint:
uv run ruff check .
# Auto fix Python lint with ruff
lint-fix:
uv run ruff check . --fix
# Run backend tests
test-backend:
uv run pytest tests/ -v
# Run frontend tests
test-frontend:
cd mta-sign-ui && pnpm test
# Run all tests
test: test-backend test-frontend
# Build frontend for production
build-ui:
cd mta-sign-ui && pnpm build
rm -rf static
cp -r mta-sign-ui/out static
# Build multi-arch container image
containers:
podman build --platform linux/arm64,linux/amd64 -f docker/Dockerfile --manifest chaos2theory/pi-mta-sign:test .
podman manifest push --all chaos2theory/pi-mta-sign:test
podman manifest rm chaos2theory/pi-mta-sign:test
# Build container image (local arch only)
build-container:
podman build -f docker/Dockerfile -t pi-mta-sign:local .

59
main.py Normal file
View File

@@ -0,0 +1,59 @@
import logging
import os
from pathlib import Path
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from starlette.middleware.cors import CORSMiddleware
from mta_sign_server.router import router as default_router
from mta_sign_server.mta.router import router as mta_router
from mta_sign_server.config.router import router as config_router
load_dotenv()
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=['*']
)
app.include_router(default_router)
app.include_router(mta_router)
app.include_router(config_router)
logger = logging.getLogger("main")
# Serve static files from the Next.js build output
# In production, the 'static' directory contains the built frontend
# Set FRONTEND_ENABLE=false to disable serving static files
frontend_enabled = os.getenv("FRONTEND_ENABLE", "true").lower() not in ("false", "0", "no")
static_dir = Path(__file__).parent / "static"
if frontend_enabled and static_dir.exists():
# Serve Next.js static assets (_next directory)
next_static = static_dir / "_next"
if next_static.exists():
app.mount("/_next", StaticFiles(directory=str(next_static)), name="next-static")
# Serve other static files (images, etc.)
app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
@app.get("/")
async def serve_index():
"""Serve the main index.html for the SPA"""
return FileResponse(static_dir / "index.html")
@app.get("/{path:path}")
async def serve_spa(path: str):
"""Serve static files or fall back to index.html for SPA routing"""
file_path = static_dir / path
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
# Fall back to index.html for client-side routing
return FileResponse(static_dir / "index.html")
if __name__ == "__main__":
uvicorn.run("main:app", host="0.0.0.0", port=8000, log_level="info", reload=True)

View File

@@ -0,0 +1,3 @@
{
"extends": "next/core-web-vitals"
}

37
mta-sign-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,37 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
.yarn

1
mta-sign-ui/.nvmrc Normal file
View File

@@ -0,0 +1 @@
v20

34
mta-sign-ui/README.md Normal file
View File

@@ -0,0 +1,34 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

BIN
mta-sign-ui/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,12 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html {
background-color: #1f2937;
color: #fff;
}
body {
@apply bg-gray-900 text-white;
}

View File

@@ -0,0 +1,19 @@
import './globals.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Pi MTA Sign!',
description: 'Using a raspberry pi to display subway information!',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}

175
mta-sign-ui/app/page.tsx Normal file
View File

@@ -0,0 +1,175 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { PlusIcon } from '@heroicons/react/24/outline'
import Header from '@/components/Header'
import StationCard from '@/components/trains/StationCard'
import { AppConfig, StationConfig, CONFIG_VERSION } from '@/types/config'
import axios from 'axios'
const generateId = () => Math.random().toString(36).substring(2, 11)
const DEFAULT_CONFIGS: StationConfig[] = [
{ id: generateId(), stationId: '127', stationName: 'Times Sq-42 St', showNorth: true, showSouth: true, selectedLines: [] },
{ id: generateId(), stationId: 'A27', stationName: 'Times Sq-42 St', showNorth: true, showSouth: true, selectedLines: [] },
]
const STORAGE_KEY = 'mta-sign-config'
export default function Home() {
const [stationConfigs, setStationConfigs] = useState<StationConfig[]>(DEFAULT_CONFIGS)
const [availableLines, setAvailableLines] = useState<string[]>([])
const [startTime, setStartTime] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<string | null>(null)
const [isLoaded, setIsLoaded] = useState(false)
// Load config from localStorage on mount
useEffect(() => {
if (typeof window === 'undefined') return
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) {
const config = JSON.parse(saved)
if (config.stations && Array.isArray(config.stations)) {
// Ensure all stations have IDs
const stationsWithIds = config.stations.map((s: any) => ({
...s,
id: s.id || generateId(),
}))
setStationConfigs(stationsWithIds)
}
}
} catch (err) {
console.error('Error loading config from localStorage:', err)
}
setIsLoaded(true)
}, [])
useEffect(() => {
axios.get('/api/lines')
.then(response => {
const lines = response.data.lines || []
setAvailableLines(lines)
// Update default configs to include all lines
setStationConfigs(prev => prev.map(config => ({
...config,
selectedLines: config.selectedLines.length === 0 ? lines : config.selectedLines
})))
})
.catch(err => console.error('Error fetching lines:', err))
}, [])
useEffect(() => {
axios.post('/api/start_time')
.then(response => {
if (response.data) {
setStartTime(new Date(response.data).toLocaleString('en-US'))
}
})
.catch(err => console.error('Error fetching start time:', err))
}, [])
useEffect(() => {
const interval = setInterval(() => {
setLastUpdated(new Date().toLocaleString('en-US'))
}, 5000)
return () => clearInterval(interval)
}, [])
// Save config to localStorage whenever it changes
useEffect(() => {
if (typeof window === 'undefined') return
try {
const configToSave = {
version: CONFIG_VERSION,
stations: stationConfigs,
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(configToSave))
} catch (err) {
console.error('Error saving config to localStorage:', err)
}
}, [stationConfigs])
const handleConfigChange = useCallback((configId: string, newConfig: StationConfig) => {
setStationConfigs(prevConfigs => {
const updated = prevConfigs.map(config => config.id === configId ? newConfig : config)
return updated
})
}, [])
const addStation = () => {
const newConfig: StationConfig = {
id: generateId(),
stationId: '',
stationName: '',
showNorth: true,
showSouth: true,
selectedLines: availableLines,
}
setStationConfigs([...stationConfigs, newConfig])
}
const removeStation = (configId: string) => {
setStationConfigs(stationConfigs.filter(config => config.id !== configId))
}
const exportConfig = useCallback((): AppConfig => {
return {
version: CONFIG_VERSION,
stations: stationConfigs,
}
}, [stationConfigs])
const importConfig = useCallback((config: AppConfig) => {
if (config.version !== CONFIG_VERSION) {
alert(`Config version mismatch. Expected ${CONFIG_VERSION}, got ${config.version}`)
return
}
// Ensure all stations have IDs
const stationsWithIds = config.stations.map(station => ({
...station,
id: station.id || generateId(),
}))
setStationConfigs(stationsWithIds)
}, [])
if (availableLines.length === 0) {
return (
<main className="min-h-screen bg-gray-900 flex items-center justify-center">
<div className="text-white text-xl">Loading...</div>
</main>
)
}
return (
<main className="min-h-screen bg-gray-900 flex flex-col">
<Header
startTime={startTime}
lastUpdated={lastUpdated}
onExportConfig={exportConfig}
onImportConfig={importConfig}
/>
<div className="flex-1 p-2 md:p-4 space-y-4 md:space-y-6">
{stationConfigs.map((config) => (
<StationCard
key={config.id}
configId={config.id}
initialConfig={config.stationId ? config : undefined}
availableLines={availableLines}
onConfigChange={handleConfigChange}
onRemove={() => removeStation(config.id)}
/>
))}
<button
onClick={addStation}
className="w-full p-4 border-2 border-dashed border-gray-600 rounded-lg text-gray-400 hover:border-gray-500 hover:text-gray-300 transition-colors flex items-center justify-center gap-2"
>
<PlusIcon className="w-5 h-5" />
Add Station
</button>
</div>
</main>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useRef } from 'react'
import Image from 'next/image'
import { ArrowDownTrayIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'
import { AppConfig } from '@/types/config'
interface HeaderProps {
startTime: string | null
lastUpdated: string | null
onExportConfig: () => AppConfig
onImportConfig: (config: AppConfig) => void
}
export default function Header({ startTime, lastUpdated, onExportConfig, onImportConfig }: HeaderProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const handleExport = () => {
const config = onExportConfig()
const blob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'mta-sign-config.json'
a.click()
URL.revokeObjectURL(url)
}
const handleImport = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = (e) => {
try {
const config = JSON.parse(e.target?.result as string) as AppConfig
onImportConfig(config)
} catch (err) {
console.error('Failed to parse config file:', err)
alert('Invalid config file')
}
}
reader.readAsText(file)
// Reset input so same file can be imported again
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<nav className="bg-gray-900 w-full px-4 py-3 md:px-6 md:py-4">
<div className="flex flex-col lg:flex-row items-center justify-between gap-2 lg:gap-4">
<div className="flex items-center gap-2 md:gap-4">
<Image
src="/images/RPI-LOGO.png"
alt="Raspberry Pi Logo"
width={50}
height={50}
className="w-8 h-8 md:w-10 md:h-10 lg:w-12 lg:h-12"
/>
<h1 className="text-2xl md:text-4xl lg:text-5xl xl:text-6xl font-bold text-white">
Pi MTA Display!
</h1>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-2">
<button
onClick={handleExport}
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
title="Export config"
>
<ArrowDownTrayIcon className="w-5 h-5 text-white" />
</button>
<button
onClick={() => fileInputRef.current?.click()}
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
title="Import config"
>
<ArrowUpTrayIcon className="w-5 h-5 text-white" />
</button>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImport}
className="hidden"
/>
</div>
<div className="flex flex-col items-center lg:items-end text-sm md:text-base lg:text-lg xl:text-xl">
<div className="text-white">
<span className="font-semibold">Last Updated:</span>{' '}
<span>{lastUpdated || 'Loading...'}</span>
</div>
<div className="text-white">
<span className="font-semibold">Started:</span>{' '}
<span>{startTime || 'Loading...'}</span>
</div>
</div>
</div>
</div>
</nav>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import axios from 'axios'
export interface Station {
id: string
name: string
}
interface StationSelectorProps {
selectedStation: Station | null
onSelect: (station: Station) => void
}
export default function StationSelector({ selectedStation, onSelect }: StationSelectorProps) {
const [search, setSearch] = useState('')
const [stations, setStations] = useState<Station[]>([])
const [isOpen, setIsOpen] = useState(false)
const [loading, setLoading] = useState(false)
const dropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const fetchStations = async () => {
setLoading(true)
try {
const params = search ? { search } : {}
const response = await axios.get('/api/stations', { params })
setStations(response.data.stations || [])
} catch (err) {
console.error('Error fetching stations:', err)
} finally {
setLoading(false)
}
}
const debounce = setTimeout(fetchStations, 300)
return () => clearTimeout(debounce)
}, [search])
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleSelect = (station: Station) => {
onSelect(station)
setSearch('')
setIsOpen(false)
}
return (
<div ref={dropdownRef} className="relative w-full">
<div
className="flex items-center gap-2 bg-gray-700 px-3 py-2 cursor-pointer"
onClick={() => setIsOpen(!isOpen)}
>
<input
type="text"
placeholder={selectedStation?.name || 'Search stations...'}
value={search}
onChange={(e) => {
setSearch(e.target.value)
setIsOpen(true)
}}
onClick={(e) => {
e.stopPropagation()
setIsOpen(true)
}}
className="flex-1 bg-transparent text-white placeholder-gray-400 outline-none text-sm md:text-base lg:text-lg"
/>
<ChevronDownIcon
className={`w-4 h-4 text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-gray-800 border border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{loading ? (
<div className="p-3 text-gray-400 text-center">Loading...</div>
) : stations.length === 0 ? (
<div className="p-3 text-gray-400 text-center">No stations found</div>
) : (
stations.map((station) => (
<div
key={station.id}
onClick={() => handleSelect(station)}
className={`px-3 py-2 cursor-pointer hover:bg-gray-700 transition-colors ${
selectedStation?.id === station.id ? 'bg-gray-700' : ''
}`}
>
<span className="text-white text-sm md:text-base">{station.name}</span>
<span className="text-gray-500 text-xs ml-2">({station.id})</span>
</div>
))
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,42 @@
'use client'
import TrainLine from './TrainLine'
interface Route {
routeId: string
arrival_times: number[]
}
interface DirectionCardProps {
direction: 'North' | 'South'
routes: Route[]
}
const DirectionCard = ({ direction, routes }: DirectionCardProps) => {
return (
<div className="bg-gray-800 overflow-hidden flex-1">
<div className="bg-gray-700 px-3 py-2 md:px-4 md:py-3 lg:px-6 lg:py-4">
<h2 className="text-xl md:text-2xl lg:text-3xl xl:text-4xl font-bold text-white">
{direction}
</h2>
</div>
<div className="divide-y divide-gray-700">
{routes && routes.length > 0 ? (
routes.map((route, index) => (
<TrainLine
key={`${route.routeId}-${index}`}
routeId={route.routeId}
arrivalTimes={route.arrival_times}
/>
))
) : (
<div className="p-4 text-gray-400 text-center">
No trains available
</div>
)}
</div>
</div>
)
}
export default DirectionCard

View File

@@ -0,0 +1,272 @@
'use client'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import axios from 'axios'
import StationSelector, { Station } from '../StationSelector'
import DirectionCard from './DirectionCard'
import { StationConfig } from '@/types/config'
interface Route {
routeId: string
arrival_times: number[]
}
interface StationData {
stationId: string
routes: Route[]
}
interface StationCardProps {
configId: string
initialConfig?: StationConfig
availableLines: string[]
onConfigChange?: (configId: string, config: StationConfig) => void
onRemove?: () => void
}
export default function StationCard({ configId, initialConfig, availableLines, onConfigChange, onRemove }: StationCardProps) {
const [selectedStation, setSelectedStation] = useState<Station | null>(
initialConfig ? { id: initialConfig.stationId, name: initialConfig.stationName } : null
)
const [showNorth, setShowNorth] = useState(initialConfig?.showNorth ?? true)
const [showSouth, setShowSouth] = useState(initialConfig?.showSouth ?? true)
const [selectedLines, setSelectedLines] = useState<Set<string>>(
new Set(initialConfig?.selectedLines ?? availableLines)
)
const [northData, setNorthData] = useState<StationData | null>(null)
const [southData, setSouthData] = useState<StationData | null>(null)
const notifyConfigChange = useCallback(() => {
if (onConfigChange && selectedStation) {
onConfigChange(configId, {
id: configId,
stationId: selectedStation.id,
stationName: selectedStation.name,
showNorth,
showSouth,
selectedLines: Array.from(selectedLines),
})
}
}, [configId, onConfigChange, selectedStation, showNorth, showSouth, selectedLines])
useEffect(() => {
notifyConfigChange()
}, [notifyConfigChange])
const fetchStationData = useCallback(async () => {
if (!selectedStation) return
try {
if (showNorth) {
const response = await axios.post(`/api/mta/${selectedStation.id}N`)
setNorthData(response.data)
}
if (showSouth) {
const response = await axios.post(`/api/mta/${selectedStation.id}S`)
setSouthData(response.data)
}
} catch (err) {
console.error('Error fetching station data:', err)
}
}, [selectedStation, showNorth, showSouth])
useEffect(() => {
if (selectedStation) {
fetchStationData()
const interval = setInterval(fetchStationData, 5000)
return () => clearInterval(interval)
}
}, [selectedStation, fetchStationData])
const availableDirections = useMemo(() => {
const directions = new Set<string>()
if (northData && northData.routes.length > 0) directions.add('North')
if (southData && southData.routes.length > 0) directions.add('South')
return directions
}, [northData, southData])
const stationAvailableLines = useMemo(() => {
const lines = new Set<string>()
if (northData) {
northData.routes.forEach(route => {
const lineId = route.routeId.replace('Route.', '')
lines.add(lineId)
})
}
if (southData) {
southData.routes.forEach(route => {
const lineId = route.routeId.replace('Route.', '')
lines.add(lineId)
})
}
return Array.from(lines).sort()
}, [northData, southData])
const toggleLine = (line: string) => {
const newSelected = new Set(selectedLines)
if (newSelected.has(line)) {
newSelected.delete(line)
} else {
newSelected.add(line)
}
setSelectedLines(newSelected)
}
const toggleDirection = (direction: 'North' | 'South') => {
if (direction === 'North') {
setShowNorth(!showNorth)
} else {
setShowSouth(!showSouth)
}
}
const filterRoutes = (routes: Route[]): Route[] => {
return routes.filter(route => {
const lineId = route.routeId.replace('Route.', '')
return selectedLines.has(lineId)
})
}
return (
<div className="bg-gray-800 overflow-hidden">
<Header
selectedStation={selectedStation}
onSelectStation={setSelectedStation}
showNorth={showNorth}
showSouth={showSouth}
selectedLines={selectedLines}
availableDirections={availableDirections}
availableLines={stationAvailableLines}
onToggleDirection={toggleDirection}
onToggleLine={toggleLine}
onRemove={onRemove}
/>
<Content
selectedStation={selectedStation}
showNorth={showNorth}
showSouth={showSouth}
northRoutes={northData ? filterRoutes(northData.routes || []) : []}
southRoutes={southData ? filterRoutes(southData.routes || []) : []}
/>
</div>
)
}
interface HeaderProps {
selectedStation: Station | null
onSelectStation: (station: Station) => void
showNorth: boolean
showSouth: boolean
selectedLines: Set<string>
availableDirections: Set<string>
availableLines: string[]
onToggleDirection: (direction: 'North' | 'South') => void
onToggleLine: (line: string) => void
onRemove?: () => void
}
function Header({
selectedStation,
onSelectStation,
showNorth,
showSouth,
selectedLines,
availableDirections,
availableLines,
onToggleDirection,
onToggleLine,
onRemove,
}: HeaderProps) {
return (
<div className="bg-gray-700 px-3 py-2 md:px-4 md:py-3 flex items-center gap-2 md:gap-4">
<div className="flex-1">
<StationSelector selectedStation={selectedStation} onSelect={onSelectStation} />
</div>
<div className="flex items-center gap-2 md:gap-3 shrink-0">
{availableDirections.has('North') && (
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={showNorth}
onChange={() => onToggleDirection('North')}
className="w-4 h-4 rounded"
/>
<span className="text-white text-xs md:text-sm">N</span>
</label>
)}
{availableDirections.has('South') && (
<label className="flex items-center gap-1 cursor-pointer">
<input
type="checkbox"
checked={showSouth}
onChange={() => onToggleDirection('South')}
className="w-4 h-4 rounded"
/>
<span className="text-white text-xs md:text-sm">S</span>
</label>
)}
<div className="flex gap-1 md:gap-2">
{availableLines.map(line => (
<button
key={line}
onClick={() => onToggleLine(line)}
className={`w-6 h-6 md:w-7 md:h-7 rounded-full text-xs font-bold transition-all ${
selectedLines.has(line) ? 'bg-blue-600 text-white' : 'bg-gray-600 text-gray-400'
}`}
>
{line}
</button>
))}
</div>
{onRemove && (
<button
onClick={onRemove}
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 transition-colors shrink-0"
title="Remove station"
>
<XMarkIcon className="w-4 h-4 md:w-5 md:h-5 text-white" />
</button>
)}
</div>
</div>
)
}
interface ContentProps {
selectedStation: Station | null
showNorth: boolean
showSouth: boolean
northRoutes: Route[]
southRoutes: Route[]
}
function Content({ selectedStation, showNorth, showSouth, northRoutes, southRoutes }: ContentProps) {
if (!selectedStation) {
return (
<div className="p-8 text-center text-gray-400">
Select a station to view train arrivals
</div>
)
}
return (
<div className="flex flex-col md:flex-row">
{showNorth && (
<div className={`flex-1 ${showSouth ? 'border-b md:border-b-0 md:border-r border-gray-700' : ''}`}>
<DirectionCard direction="North" routes={northRoutes} />
</div>
)}
{showSouth && (
<div className="flex-1">
<DirectionCard direction="South" routes={southRoutes} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,43 @@
'use client'
import Image from 'next/image'
interface TrainLineProps {
routeId: string
arrivalTimes: number[]
}
const TrainLine = ({ routeId, arrivalTimes }: TrainLineProps) => {
const formatTimes = (times: number[]): string => {
if (!times || times.length === 0) {
return 'No trains'
}
return times
.sort((a, b) => a - b)
.join(', ')
}
const getLineImage = (route: string): string => {
const cleanRoute = route.replace('Route.', '')
return `/images/lines/${cleanRoute}.svg`
}
return (
<div className="flex items-center justify-between p-2 md:p-3 lg:p-4 border-b border-gray-700 last:border-b-0 h-16 md:h-20 lg:h-24">
<div className="flex items-center gap-2 md:gap-3 lg:gap-4 shrink-0">
<Image
src={getLineImage(routeId)}
alt={`${routeId} line`}
width={60}
height={60}
className="w-8 h-8 md:w-12 md:h-12 lg:w-14 lg:h-14 xl:w-16 xl:h-16"
/>
</div>
<h2 className="text-xl md:text-2xl lg:text-[2.5rem] font-bold text-white text-right flex-1 ml-4 whitespace-nowrap overflow-hidden text-ellipsis">
{formatTimes(arrivalTimes)}
</h2>
</div>
)
}
export default TrainLine

View File

@@ -0,0 +1,4 @@
wwwroot/*.js
node_modules
typings
dist

View File

@@ -0,0 +1 @@
# empty npmignore to ensure all required files (e.g., in the dist folder) are published by npm

View File

@@ -0,0 +1,23 @@
# OpenAPI Generator Ignore
# Generated by openapi-generator https://github.com/openapitools/openapi-generator
# Use this file to prevent files from being overwritten by the generator.
# The patterns follow closely to .gitignore or .dockerignore.
# As an example, the C# client generator defines ApiClient.cs.
# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line:
#ApiClient.cs
# You can match any string of characters against a directory, file or extension with a single asterisk (*):
#foo/*/qux
# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux
# You can recursively match patterns against a directory, file or extension with a double asterisk (**):
#foo/**/qux
# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux
# You can also negate patterns with an exclamation (!).
# For example, you can ignore all files in a docs folder with the file extension .md:
#docs/*.md
# Then explicitly reverse the ignore rule for a single file:
#!docs/README.md

View File

@@ -0,0 +1,8 @@
.gitignore
.npmignore
api.ts
base.ts
common.ts
configuration.ts
git_push.sh
index.ts

View File

@@ -0,0 +1 @@
6.6.0

View File

@@ -0,0 +1,559 @@
/* tslint:disable */
/* eslint-disable */
/**
* FastAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from './common';
import type { RequestArgs } from './base';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, BaseAPI, RequiredError } from './base';
/**
*
* @export
* @interface AllStationResponse
*/
export interface AllStationResponse {
/**
*
* @type {any}
* @memberof AllStationResponse
*/
'stations': any;
}
/**
*
* @export
* @interface HTTPValidationError
*/
export interface HTTPValidationError {
/**
*
* @type {any}
* @memberof HTTPValidationError
*/
'detail'?: any;
}
/**
* An enumeration.
* @export
* @interface Route
*/
export interface Route {
}
/**
*
* @export
* @interface RouteResponse
*/
export interface RouteResponse {
/**
*
* @type {Route}
* @memberof RouteResponse
*/
'routeId': Route;
/**
*
* @type {any}
* @memberof RouteResponse
*/
'arrival_times': any;
}
/**
*
* @export
* @interface StationResponse
*/
export interface StationResponse {
/**
*
* @type {any}
* @memberof StationResponse
*/
'stationId': any;
/**
*
* @type {any}
* @memberof StationResponse
*/
'routes': any;
}
/**
*
* @export
* @interface ValidationError
*/
export interface ValidationError {
/**
*
* @type {any}
* @memberof ValidationError
*/
'loc': any;
/**
*
* @type {any}
* @memberof ValidationError
*/
'msg': any;
/**
*
* @type {any}
* @memberof ValidationError
*/
'type': any;
}
/**
* ConfigApi - axios parameter creator
* @export
*/
export const ConfigApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllApiConfigGet: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/config`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* ConfigApi - functional programming interface
* @export
*/
export const ConfigApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ConfigApiAxiosParamCreator(configuration)
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllApiConfigGet(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllApiConfigGet(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* ConfigApi - factory interface
* @export
*/
export const ConfigApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ConfigApiFp(configuration)
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllApiConfigGet(options?: any): AxiosPromise<any> {
return localVarFp.getAllApiConfigGet(options).then((request) => request(axios, basePath));
},
};
};
/**
* ConfigApi - object-oriented interface
* @export
* @class ConfigApi
* @extends {BaseAPI}
*/
export class ConfigApi extends BaseAPI {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ConfigApi
*/
public getAllApiConfigGet(options?: AxiosRequestConfig) {
return ConfigApiFp(this.configuration).getAllApiConfigGet(options).then((request) => request(this.axios, this.basePath));
}
}
/**
* MtaDataApi - axios parameter creator
* @export
*/
export const MtaDataApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllApiMtaPost: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/mta`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get Route
* @param {any} stopId
* @param {Route} route
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRouteApiMtaStopIdRoutePost: async (stopId: any, route: Route, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'stopId' is not null or undefined
assertParamExists('getRouteApiMtaStopIdRoutePost', 'stopId', stopId)
// verify required parameter 'route' is not null or undefined
assertParamExists('getRouteApiMtaStopIdRoutePost', 'route', route)
const localVarPath = `/api/mta/{stop_id}/{route}`
.replace(`{${"stop_id"}}`, encodeURIComponent(String(stopId)))
.replace(`{${"route"}}`, encodeURIComponent(String(route)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get Station
* @param {any} stopId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStationApiMtaStopIdPost: async (stopId: any, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'stopId' is not null or undefined
assertParamExists('getStationApiMtaStopIdPost', 'stopId', stopId)
const localVarPath = `/api/mta/{stop_id}`
.replace(`{${"stop_id"}}`, encodeURIComponent(String(stopId)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* MtaDataApi - functional programming interface
* @export
*/
export const MtaDataApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = MtaDataApiAxiosParamCreator(configuration)
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllApiMtaPost(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AllStationResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllApiMtaPost(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @summary Get Route
* @param {any} stopId
* @param {Route} route
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getRouteApiMtaStopIdRoutePost(stopId: any, route: Route, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<RouteResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getRouteApiMtaStopIdRoutePost(stopId, route, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @summary Get Station
* @param {any} stopId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getStationApiMtaStopIdPost(stopId: any, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<StationResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStationApiMtaStopIdPost(stopId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* MtaDataApi - factory interface
* @export
*/
export const MtaDataApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = MtaDataApiFp(configuration)
return {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllApiMtaPost(options?: any): AxiosPromise<AllStationResponse> {
return localVarFp.getAllApiMtaPost(options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get Route
* @param {any} stopId
* @param {Route} route
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getRouteApiMtaStopIdRoutePost(stopId: any, route: Route, options?: any): AxiosPromise<RouteResponse> {
return localVarFp.getRouteApiMtaStopIdRoutePost(stopId, route, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get Station
* @param {any} stopId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStationApiMtaStopIdPost(stopId: any, options?: any): AxiosPromise<StationResponse> {
return localVarFp.getStationApiMtaStopIdPost(stopId, options).then((request) => request(axios, basePath));
},
};
};
/**
* MtaDataApi - object-oriented interface
* @export
* @class MtaDataApi
* @extends {BaseAPI}
*/
export class MtaDataApi extends BaseAPI {
/**
*
* @summary Get All
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MtaDataApi
*/
public getAllApiMtaPost(options?: AxiosRequestConfig) {
return MtaDataApiFp(this.configuration).getAllApiMtaPost(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get Route
* @param {any} stopId
* @param {Route} route
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MtaDataApi
*/
public getRouteApiMtaStopIdRoutePost(stopId: any, route: Route, options?: AxiosRequestConfig) {
return MtaDataApiFp(this.configuration).getRouteApiMtaStopIdRoutePost(stopId, route, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get Station
* @param {any} stopId
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MtaDataApi
*/
public getStationApiMtaStopIdPost(stopId: any, options?: AxiosRequestConfig) {
return MtaDataApiFp(this.configuration).getStationApiMtaStopIdPost(stopId, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* StartApi - axios parameter creator
* @export
*/
export const StartApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @summary Get Start Time
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStartTimeApiStartTimePost: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/start_time`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* StartApi - functional programming interface
* @export
*/
export const StartApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = StartApiAxiosParamCreator(configuration)
return {
/**
*
* @summary Get Start Time
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getStartTimeApiStartTimePost(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<any>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStartTimeApiStartTimePost(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* StartApi - factory interface
* @export
*/
export const StartApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = StartApiFp(configuration)
return {
/**
*
* @summary Get Start Time
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStartTimeApiStartTimePost(options?: any): AxiosPromise<any> {
return localVarFp.getStartTimeApiStartTimePost(options).then((request) => request(axios, basePath));
},
};
};
/**
* StartApi - object-oriented interface
* @export
* @class StartApi
* @extends {BaseAPI}
*/
export class StartApi extends BaseAPI {
/**
*
* @summary Get Start Time
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof StartApi
*/
public getStartTimeApiStartTimePost(options?: AxiosRequestConfig) {
return StartApiFp(this.configuration).getStartTimeApiStartTimePost(options).then((request) => request(this.axios, this.basePath));
}
}

View File

@@ -0,0 +1,72 @@
/* tslint:disable */
/* eslint-disable */
/**
* FastAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from './configuration';
// Some imports not used depending on template conditions
// @ts-ignore
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
export const BASE_PATH = "http://localhost".replace(/\/+$/, "");
/**
*
* @export
*/
export const COLLECTION_FORMATS = {
csv: ",",
ssv: " ",
tsv: "\t",
pipes: "|",
};
/**
*
* @export
* @interface RequestArgs
*/
export interface RequestArgs {
url: string;
options: AxiosRequestConfig;
}
/**
*
* @export
* @class BaseAPI
*/
export class BaseAPI {
protected configuration: Configuration | undefined;
constructor(configuration?: Configuration, protected basePath: string = BASE_PATH, protected axios: AxiosInstance = globalAxios) {
if (configuration) {
this.configuration = configuration;
this.basePath = configuration.basePath || this.basePath;
}
}
};
/**
*
* @export
* @class RequiredError
* @extends {Error}
*/
export class RequiredError extends Error {
constructor(public field: string, msg?: string) {
super(msg);
this.name = "RequiredError"
}
}

View File

@@ -0,0 +1,150 @@
/* tslint:disable */
/* eslint-disable */
/**
* FastAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "./configuration";
import type { RequestArgs } from "./base";
import type { AxiosInstance, AxiosResponse } from 'axios';
import { RequiredError } from "./base";
/**
*
* @export
*/
export const DUMMY_BASE_URL = 'https://example.com'
/**
*
* @throws {RequiredError}
* @export
*/
export const assertParamExists = function (functionName: string, paramName: string, paramValue: unknown) {
if (paramValue === null || paramValue === undefined) {
throw new RequiredError(paramName, `Required parameter ${paramName} was null or undefined when calling ${functionName}.`);
}
}
/**
*
* @export
*/
export const setApiKeyToObject = async function (object: any, keyParamName: string, configuration?: Configuration) {
if (configuration && configuration.apiKey) {
const localVarApiKeyValue = typeof configuration.apiKey === 'function'
? await configuration.apiKey(keyParamName)
: await configuration.apiKey;
object[keyParamName] = localVarApiKeyValue;
}
}
/**
*
* @export
*/
export const setBasicAuthToObject = function (object: any, configuration?: Configuration) {
if (configuration && (configuration.username || configuration.password)) {
object["auth"] = { username: configuration.username, password: configuration.password };
}
}
/**
*
* @export
*/
export const setBearerAuthToObject = async function (object: any, configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const accessToken = typeof configuration.accessToken === 'function'
? await configuration.accessToken()
: await configuration.accessToken;
object["Authorization"] = "Bearer " + accessToken;
}
}
/**
*
* @export
*/
export const setOAuthToObject = async function (object: any, name: string, scopes: string[], configuration?: Configuration) {
if (configuration && configuration.accessToken) {
const localVarAccessTokenValue = typeof configuration.accessToken === 'function'
? await configuration.accessToken(name, scopes)
: await configuration.accessToken;
object["Authorization"] = "Bearer " + localVarAccessTokenValue;
}
}
function setFlattenedQueryParams(urlSearchParams: URLSearchParams, parameter: any, key: string = ""): void {
if (parameter == null) return;
if (typeof parameter === "object") {
if (Array.isArray(parameter)) {
(parameter as any[]).forEach(item => setFlattenedQueryParams(urlSearchParams, item, key));
}
else {
Object.keys(parameter).forEach(currentKey =>
setFlattenedQueryParams(urlSearchParams, parameter[currentKey], `${key}${key !== '' ? '.' : ''}${currentKey}`)
);
}
}
else {
if (urlSearchParams.has(key)) {
urlSearchParams.append(key, parameter);
}
else {
urlSearchParams.set(key, parameter);
}
}
}
/**
*
* @export
*/
export const setSearchParams = function (url: URL, ...objects: any[]) {
const searchParams = new URLSearchParams(url.search);
setFlattenedQueryParams(searchParams, objects);
url.search = searchParams.toString();
}
/**
*
* @export
*/
export const serializeDataIfNeeded = function (value: any, requestOptions: any, configuration?: Configuration) {
const nonString = typeof value !== 'string';
const needsSerialization = nonString && configuration && configuration.isJsonMime
? configuration.isJsonMime(requestOptions.headers['Content-Type'])
: nonString;
return needsSerialization
? JSON.stringify(value !== undefined ? value : {})
: (value || "");
}
/**
*
* @export
*/
export const toPathString = function (url: URL) {
return url.pathname + url.search + url.hash
}
/**
*
* @export
*/
export const createRequestFunction = function (axiosArgs: RequestArgs, globalAxios: AxiosInstance, BASE_PATH: string, configuration?: Configuration) {
return <T = unknown, R = AxiosResponse<T>>(axios: AxiosInstance = globalAxios, basePath: string = BASE_PATH) => {
const axiosRequestArgs = {...axiosArgs.options, url: (configuration?.basePath || basePath) + axiosArgs.url};
return axios.request<T, R>(axiosRequestArgs);
};
}

View File

@@ -0,0 +1,101 @@
/* tslint:disable */
/* eslint-disable */
/**
* FastAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface ConfigurationParameters {
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
username?: string;
password?: string;
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
basePath?: string;
baseOptions?: any;
formDataCtor?: new () => any;
}
export class Configuration {
/**
* parameter for apiKey security
* @param name security name
* @memberof Configuration
*/
apiKey?: string | Promise<string> | ((name: string) => string) | ((name: string) => Promise<string>);
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
username?: string;
/**
* parameter for basic security
*
* @type {string}
* @memberof Configuration
*/
password?: string;
/**
* parameter for oauth2 security
* @param name security name
* @param scopes oauth2 scope
* @memberof Configuration
*/
accessToken?: string | Promise<string> | ((name?: string, scopes?: string[]) => string) | ((name?: string, scopes?: string[]) => Promise<string>);
/**
* override base path
*
* @type {string}
* @memberof Configuration
*/
basePath?: string;
/**
* base options for axios calls
*
* @type {any}
* @memberof Configuration
*/
baseOptions?: any;
/**
* The FormData constructor that will be used to create multipart form data
* requests. You can inject this here so that execution environments that
* do not support the FormData class can still run the generated client.
*
* @type {new () => FormData}
*/
formDataCtor?: new () => any;
constructor(param: ConfigurationParameters = {}) {
this.apiKey = param.apiKey;
this.username = param.username;
this.password = param.password;
this.accessToken = param.accessToken;
this.basePath = param.basePath;
this.baseOptions = param.baseOptions;
this.formDataCtor = param.formDataCtor;
}
/**
* Check if the given MIME is a JSON MIME.
* JSON MIME examples:
* application/json
* application/json; charset=UTF8
* APPLICATION/JSON
* application/vnd.company+json
* @param mime - MIME (Multipurpose Internet Mail Extensions)
* @return True if the given MIME is JSON, false otherwise.
*/
public isJsonMime(mime: string): boolean {
const jsonMime: RegExp = new RegExp('^(application\/json|[^;/ \t]+\/[^;/ \t]+[+]json)[ \t]*(;.*)?$', 'i');
return mime !== null && (jsonMime.test(mime) || mime.toLowerCase() === 'application/json-patch+json');
}
}

View File

@@ -0,0 +1,57 @@
#!/bin/sh
# ref: https://help.github.com/articles/adding-an-existing-project-to-github-using-the-command-line/
#
# Usage example: /bin/sh ./git_push.sh wing328 openapi-petstore-perl "minor update" "gitlab.com"
git_user_id=$1
git_repo_id=$2
release_note=$3
git_host=$4
if [ "$git_host" = "" ]; then
git_host="github.com"
echo "[INFO] No command line input provided. Set \$git_host to $git_host"
fi
if [ "$git_user_id" = "" ]; then
git_user_id="GIT_USER_ID"
echo "[INFO] No command line input provided. Set \$git_user_id to $git_user_id"
fi
if [ "$git_repo_id" = "" ]; then
git_repo_id="GIT_REPO_ID"
echo "[INFO] No command line input provided. Set \$git_repo_id to $git_repo_id"
fi
if [ "$release_note" = "" ]; then
release_note="Minor update"
echo "[INFO] No command line input provided. Set \$release_note to $release_note"
fi
# Initialize the local directory as a Git repository
git init
# Adds the files in the local repository and stages them for commit.
git add .
# Commits the tracked changes and prepares them to be pushed to a remote repository.
git commit -m "$release_note"
# Sets the new remote
git_remote=$(git remote)
if [ "$git_remote" = "" ]; then # git remote not defined
if [ "$GIT_TOKEN" = "" ]; then
echo "[INFO] \$GIT_TOKEN (environment variable) is not set. Using the git credential in your environment."
git remote add origin https://${git_host}/${git_user_id}/${git_repo_id}.git
else
git remote add origin https://${git_user_id}:"${GIT_TOKEN}"@${git_host}/${git_user_id}/${git_repo_id}.git
fi
fi
git pull origin master
# Pushes (Forces) the changes in the local repository up to the remote repository
echo "Git pushing to https://${git_host}/${git_user_id}/${git_repo_id}.git"
git push origin master 2>&1 | grep -v 'To https'

View File

@@ -0,0 +1,18 @@
/* tslint:disable */
/* eslint-disable */
/**
* FastAPI
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export * from "./api";
export * from "./configuration";

View File

@@ -0,0 +1 @@
{"openapi":"3.1.0","info":{"title":"FastAPI","version":"0.1.0"},"paths":{"/api/start_time":{"post":{"tags":["start"],"summary":"Get Start Time","operationId":"get_start_time_api_start_time_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}},"/api/mta/{stop_id}/{route}":{"post":{"tags":["mta-data"],"summary":"Get Route","operationId":"get_route_api_mta__stop_id___route__post","parameters":[{"required":true,"schema":{"type":"string","title":"Stop Id"},"name":"stop_id","in":"path"},{"required":true,"schema":{"$ref":"#/components/schemas/Route"},"name":"route","in":"path"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RouteResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/mta/{stop_id}":{"post":{"tags":["mta-data"],"summary":"Get Station","operationId":"get_station_api_mta__stop_id__post","parameters":[{"required":true,"schema":{"type":"string","title":"Stop Id"},"name":"stop_id","in":"path"}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/StationResponse"}}}},"422":{"description":"Validation Error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HTTPValidationError"}}}}}}},"/api/mta":{"post":{"tags":["mta-data"],"summary":"Get All","operationId":"get_all_api_mta_post","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AllStationResponse"}}}}}}},"/api/config":{"get":{"tags":["config"],"summary":"Get All","operationId":"get_all_api_config_get","responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{}}}}}}}},"components":{"schemas":{"AllStationResponse":{"properties":{"stations":{"items":{"$ref":"#/components/schemas/StationResponse"},"type":"array","title":"Stations"}},"type":"object","required":["stations"],"title":"AllStationResponse"},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"Route":{"enum":["A","C","E","B","D","F","M","G","J","Z","N","Q","R","W","1","2","3","4","5","6","7","L","SIR"],"title":"Route","description":"An enumeration."},"RouteResponse":{"properties":{"routeId":{"$ref":"#/components/schemas/Route"},"arrival_times":{"items":{"type":"integer"},"type":"array","title":"Arrival Times"}},"type":"object","required":["routeId","arrival_times"],"title":"RouteResponse"},"StationResponse":{"properties":{"stationId":{"type":"string","title":"Stationid"},"routes":{"items":{"$ref":"#/components/schemas/RouteResponse"},"type":"array","title":"Routes"}},"type":"object","required":["stationId","routes"],"title":"StationResponse"},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"}}}}

6
mta-sign-ui/next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -0,0 +1,19 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
images: {
unoptimized: true,
},
// Rewrites only work during development (next dev)
// They are ignored during static export build
rewrites: async () => {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8000/api/:path*',
},
]
},
}
module.exports = nextConfig

View File

@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json",
"spaces": 2,
"generator-cli": {
"version": "6.6.0",
"generators": {
"mta-sign-api": {
"generatorName": "typescript-axios",
"output": "#{cwd}/gen-sources/mta-sign-api/",
"glob": "gen-sources/mta-sign-api/openapi.{json,yaml}",
"additionalProperties": "supportsES6=true,typescriptThreePlus=true"
}
}
}
}

41
mta-sign-ui/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "mta-sign",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"build:watch": "chokidar 'app/**/*' 'components/**/*' 'public/**/*' -c 'next build'",
"start": "next start",
"lint": "next lint",
"test": "vitest run",
"test:watch": "vitest",
"gen-apis": "npx openapi-typescript http://localhost:8000/openapi.json --output gen-sources/mtaserver.ts"
},
"dependencies": {
"@heroicons/react": "^2.2.0",
"autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"bootstrap": "^5.3.8",
"next": "^16.1.2",
"openapi-typescript-fetch": "^2.0.0",
"postcss": "^8.5.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^3.4.17"
},
"devDependencies": {
"@testing-library/react": "^16.1.0",
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.3.4",
"chokidar-cli": "^3.0.0",
"eslint": "^9.18.0",
"eslint-config-next": "^16.1.2",
"jsdom": "^26.0.0",
"typescript": "^5.7.3",
"vitest": "^3.0.0"
},
"packageManager": "pnpm@10.28.1"
}

5742
mta-sign-ui/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="1">
<circle cx="45" cy="45" r="45" style="fill:rgb(238,52,46);"/>
<path d="M31.084,36.4388L31.084,30.1237C34.0137,29.9935 36.0645,29.7982 37.2363,29.5378C39.1026,29.1254 40.6217,28.3008 41.7936,27.0638C42.5966,26.2174 43.2042,25.089 43.6165,23.6784C43.8553,22.832 43.9746,22.2027 43.9746,21.7904L51.6895,21.7904L51.6895,68.9909L42.1842,68.9909L42.1842,36.4388L31.084,36.4388Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 856 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="2">
<circle cx="45" cy="45" r="45" style="fill:rgb(238,52,46);"/>
<path d="M30.7259,59.7135C32.0497,56.5668 35.1747,53.2357 40.1009,49.7201C44.3761,46.6602 47.143,44.4683 48.4017,43.1445C50.3331,41.0829 51.2988,38.826 51.2988,36.3737C51.2988,34.3772 50.7454,32.717 49.6387,31.3932C48.5319,30.0694 46.9477,29.4076 44.8861,29.4076C42.0649,29.4076 40.1443,30.4601 39.1243,32.5651C38.5384,33.7804 38.1912,35.7118 38.0827,38.3594L29.0658,38.3594C29.2177,34.3446 29.9447,31.1003 31.2467,28.6263C33.7207,23.9171 38.1152,21.5625 44.4303,21.5625C49.4217,21.5625 53.393,22.946 56.3444,25.7129C59.2958,28.4798 60.7715,32.1419 60.7715,36.6992C60.7715,40.1931 59.7298,43.2964 57.6465,46.0091C56.2793,47.8103 54.0332,49.8177 50.9082,52.0313L47.1973,54.668C44.8752,56.3173 43.2856,57.5109 42.4284,58.2487C41.5712,58.9865 40.8496,59.8438 40.2637,60.8203L60.8691,60.8203L60.8691,68.9909L28.5449,68.9909C28.6317,65.6055 29.3587,62.513 30.7259,59.7135Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="3">
<circle cx="45" cy="45" r="45" style="fill:rgb(238,52,46);"/>
<path d="M37.334,54.5052C37.334,56.3932 37.6378,57.9557 38.2454,59.1927C39.3739,61.4714 41.4247,62.6107 44.3978,62.6107C46.2207,62.6107 47.8103,61.9868 49.1667,60.7389C50.523,59.4911 51.2012,57.6953 51.2012,55.3516C51.2012,52.2483 49.9425,50.1758 47.4251,49.1341C45.9928,48.5482 43.7359,48.2552 40.6543,48.2552L40.6543,41.6146C43.6708,41.5712 45.7758,41.2782 46.9694,40.7357C49.031,39.8242 50.0618,37.9796 50.0618,35.2018C50.0618,33.4006 49.5356,31.9358 48.4831,30.8073C47.4306,29.6788 45.9494,29.1146 44.0397,29.1146C41.8479,29.1146 40.2365,29.809 39.2057,31.1979C38.1749,32.5868 37.6812,34.4423 37.7246,36.7643L29.0658,36.7643C29.1526,34.4206 29.554,32.1962 30.2702,30.0911C31.0297,28.2465 32.2233,26.543 33.8509,24.9805C35.0662,23.8737 36.5093,23.0273 38.1803,22.4414C39.8513,21.8555 41.9021,21.5625 44.3327,21.5625C48.8466,21.5625 52.487,22.7289 55.2539,25.0618C58.0208,27.3947 59.4043,30.5252 59.4043,34.4531C59.4043,37.2309 58.5796,39.5747 56.9303,41.4844C55.8887,42.678 54.8036,43.4918 53.6751,43.9258C54.5215,43.9258 55.7368,44.6528 57.321,46.1068C59.6864,48.2986 60.8691,51.2934 60.8691,55.0911C60.8691,59.0842 59.4857,62.5944 56.7188,65.6217C53.9518,68.6491 49.8557,70.1628 44.4303,70.1628C37.7463,70.1628 33.1022,67.9818 30.498,63.6198C29.1309,61.2977 28.3713,58.2595 28.2194,54.5052L37.334,54.5052Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="4">
<circle cx="45" cy="45" r="45" style="fill:rgb(0,147,59);"/>
<path d="M61.3249,58.8346L55.9863,58.8346L55.9863,68.9909L46.9043,68.9909L46.9043,58.8346L28.2194,58.8346L28.2194,50.7292L45.5697,22.0833L55.9863,22.0833L55.9863,51.5755L61.3249,51.5755L61.3249,58.8346ZM46.9043,51.5755L46.9043,31.1979L35.0879,51.5755L46.9043,51.5755Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 813 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="5">
<circle cx="45" cy="45" r="45" style="fill:rgb(0,147,59);"/>
<path d="M37.4642,56.3607C37.8331,58.3789 38.5384,59.936 39.5801,61.0319C40.6217,62.1278 42.1408,62.6758 44.1374,62.6758C46.4377,62.6758 48.1901,61.8674 49.3945,60.2507C50.599,58.6339 51.2012,56.5994 51.2012,54.1471C51.2012,51.7383 50.6369,49.7038 49.5085,48.0436C48.38,46.3835 46.6222,45.5534 44.235,45.5534C43.1066,45.5534 42.13,45.6944 41.3053,45.9766C39.8513,46.4974 38.7554,47.4631 38.0176,48.8737L29.6842,48.4831L33.0046,22.4089L59.0137,22.4089L59.0137,30.2865L39.7103,30.2865L38.0176,40.6055C39.4499,39.6723 40.5675,39.0538 41.3704,38.75C42.7159,38.2509 44.3544,38.0013 46.2858,38.0013C50.1921,38.0013 53.5992,39.3142 56.5072,41.9401C59.4151,44.566 60.8691,48.3854 60.8691,53.3984C60.8691,57.7604 59.4694,61.6558 56.6699,65.0846C53.8704,68.5135 49.6821,70.2279 44.1048,70.2279C39.6126,70.2279 35.9234,69.0234 33.0371,66.6146C30.1508,64.2057 28.5449,60.7878 28.2194,56.3607L37.4642,56.3607Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="6">
<circle cx="45" cy="45" r="45" style="fill:rgb(0,147,59);"/>
<path d="M39.9056,60.2669C41.2728,61.8728 43.0089,62.6758 45.1139,62.6758C47.1756,62.6758 48.7977,61.9 49.9805,60.3483C51.1632,58.7967 51.7546,56.7839 51.7546,54.3099C51.7546,51.5538 51.0818,49.4434 49.7363,47.9785C48.3908,46.5137 46.7415,45.7813 44.7884,45.7813C43.2042,45.7813 41.8045,46.2587 40.5892,47.2135C38.7663,48.6241 37.8548,50.9028 37.8548,54.0495C37.8548,56.5885 38.5384,58.661 39.9056,60.2669ZM50.9082,33.6719C50.9082,32.9123 50.6152,32.0768 50.0293,31.1654C49.031,29.6897 47.5228,28.9518 45.5046,28.9518C42.4881,28.9518 40.3396,30.6445 39.0592,34.0299C38.3648,35.8963 37.8874,38.6523 37.627,42.2982C38.7771,40.931 40.1118,39.9327 41.6309,39.3034C43.15,38.674 44.8861,38.3594 46.8392,38.3594C51.0276,38.3594 54.4618,39.7808 57.1419,42.6237C59.822,45.4666 61.1621,49.1016 61.1621,53.5286C61.1621,57.934 59.8492,61.8186 57.2233,65.1823C54.5974,68.546 50.5176,70.2279 44.9837,70.2279C39.0375,70.2279 34.6539,67.7431 31.8327,62.7734C29.6408,58.8889 28.5449,53.8759 28.5449,47.7344C28.5449,44.1319 28.6968,41.2023 29.0007,38.9453C29.5432,34.9306 30.5957,31.5885 32.1582,28.9193C33.5037,26.6406 35.2669,24.8069 37.4479,23.418C39.6289,22.0291 42.2385,21.3346 45.2767,21.3346C49.6604,21.3346 53.1543,22.4577 55.7585,24.7038C58.3626,26.9499 59.8275,29.9392 60.153,33.6719L50.9082,33.6719Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="_6-Diamond" serif:id="6 Diamond">
<g transform="matrix(0.539996,0.539996,-0.555556,0.555556,51.4781,-4.07742)">
<rect x="2.407" y="5" width="92.593" height="90" style="fill:rgb(0,147,59);"/>
</g>
<g transform="matrix(1.26802,0,0,1.26802,21.9029,39.5201)">
<path d="M18.141,20.305C19.219,21.571 20.588,22.204 22.248,22.204C23.874,22.204 25.153,21.593 26.086,20.369C27.019,19.145 27.485,17.558 27.485,15.607C27.485,13.433 26.954,11.769 25.893,10.614C24.832,9.458 23.532,8.881 21.991,8.881C20.742,8.881 19.638,9.257 18.68,10.01C17.242,11.123 16.523,12.92 16.523,15.401C16.523,17.404 17.062,19.038 18.141,20.305ZM26.818,-0.669C26.818,-1.268 26.587,-1.927 26.124,-2.646C25.337,-3.809 24.148,-4.391 22.556,-4.391C20.177,-4.391 18.483,-3.056 17.473,-0.387C16.925,1.085 16.549,3.259 16.344,6.134C17.251,5.056 18.303,4.269 19.501,3.772C20.699,3.276 22.068,3.028 23.609,3.028C26.912,3.028 29.62,4.149 31.734,6.391C33.847,8.633 34.904,11.499 34.904,14.991C34.904,18.465 33.869,21.528 31.798,24.181C29.727,26.834 26.51,28.16 22.145,28.16C17.456,28.16 13.999,26.201 11.774,22.281C10.045,19.218 9.181,15.265 9.181,10.421C9.181,7.58 9.301,5.27 9.541,3.49C9.968,0.324 10.799,-2.312 12.031,-4.417C13.092,-6.214 14.482,-7.66 16.202,-8.756C17.922,-9.851 19.98,-10.398 22.376,-10.398C25.834,-10.398 28.589,-9.513 30.643,-7.741C32.696,-5.97 33.852,-3.613 34.108,-0.669L26.818,-0.669Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="7">
<circle cx="45" cy="45" r="45" style="fill:rgb(185,51,174);"/>
<path d="M61.6178,29.668C60.2289,31.0352 58.2975,33.4711 55.8236,36.9759C53.3496,40.4807 51.2771,44.0994 49.6061,47.832C48.2823,50.7617 47.0888,54.3424 46.0254,58.5742C44.962,62.806 44.4303,66.2782 44.4303,68.9909L34.7949,68.9909C35.077,60.5273 37.8548,51.7274 43.1283,42.5911C46.5354,36.9271 49.3891,32.9774 51.6895,30.7422L28.1543,30.7422L28.2845,22.4089L61.6178,22.4089L61.6178,29.668Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 936 B

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 100 100" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="_7-Diamond" serif:id="7 Diamond">
<g transform="matrix(0.539996,0.539996,-0.555556,0.555556,51.4781,-4.07742)">
<rect x="2.407" y="5" width="92.593" height="90" style="fill:rgb(185,51,174);"/>
</g>
<g transform="matrix(1.26802,0,0,1.26802,21.9029,39.5201)">
<path d="M35.264,-3.827C34.168,-2.748 32.645,-0.827 30.694,1.937C28.743,4.701 27.109,7.554 25.791,10.498C24.747,12.809 23.805,15.632 22.967,18.97C22.128,22.307 21.709,25.045 21.709,27.185L14.11,27.185C14.333,20.51 16.523,13.57 20.682,6.365C23.369,1.898 25.62,-1.217 27.434,-2.979L8.873,-2.979L8.976,-9.551L35.264,-9.551L35.264,-3.827Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="A">
<circle cx="45" cy="45" r="45" style="fill:rgb(40,82,173);"/>
<path d="M39.1243,50.8594L51.2988,50.8594L45.3092,31.9792L39.1243,50.8594ZM39.7428,21.0091L51.071,21.0091L68.0632,68.9909L57.1908,68.9909L54.0983,59.1276L36.4225,59.1276L33.1022,68.9909L22.6204,68.9909L39.7428,21.0091Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 765 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="B">
<circle cx="45" cy="45" r="45" style="fill:rgb(255,98,25);"/>
<path d="M35.7389,29.3424L35.7389,39.9219L47.5228,39.9219C49.6278,39.9219 51.3368,39.5258 52.6497,38.7337C53.9627,37.9416 54.6191,36.5365 54.6191,34.5182C54.6191,32.283 53.7511,30.8073 52.015,30.0911C50.5176,29.592 48.6079,29.3424 46.2858,29.3424L35.7389,29.3424ZM35.7389,47.8646L35.7389,60.6576L47.5228,60.6576C49.6278,60.6576 51.2663,60.3754 52.4382,59.8112C54.5649,58.7695 55.6283,56.773 55.6283,53.8216C55.6283,51.326 54.5974,49.6115 52.5358,48.6784C51.3856,48.1576 49.7689,47.8863 47.6855,47.8646L35.7389,47.8646ZM61.9759,26.1198C63.4516,28.1597 64.1895,30.6011 64.1895,33.444C64.1895,36.3737 63.4516,38.7283 61.9759,40.5078C61.1513,41.5061 59.936,42.4175 58.3301,43.2422C60.7823,44.1319 62.6324,45.5425 63.8802,47.474C65.128,49.4054 65.752,51.7491 65.752,54.5052C65.752,57.3481 65.0358,59.898 63.6035,62.1549C62.6921,63.6523 61.5527,64.911 60.1855,65.931C58.6447,67.1029 56.8273,67.9058 54.7331,68.3398C52.6389,68.7739 50.3657,68.9909 47.9134,68.9909L26.1686,68.9909L26.1686,21.0091L49.4759,21.0091C55.357,21.0959 59.5237,22.7995 61.9759,26.1198Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="C">
<circle cx="45" cy="45" r="45" style="fill:rgb(40,82,173);"/>
<path d="M30.6608,26.0221C34.5671,22.0725 39.5367,20.0977 45.5697,20.0977C53.6426,20.0977 59.5454,22.7452 63.278,28.0404C65.3396,31.0135 66.4464,33.9974 66.5983,36.9922L56.5723,36.9922C55.9212,34.6918 55.0857,32.9557 54.0658,31.7839C52.2428,29.7005 49.541,28.6589 45.9603,28.6589C42.3145,28.6589 39.439,30.1291 37.334,33.0697C35.2289,36.0102 34.1764,40.1714 34.1764,45.5534C34.1764,50.9353 35.2886,54.9664 37.513,57.6465C39.7374,60.3266 42.564,61.6667 45.9928,61.6667C49.5085,61.6667 52.1886,60.5165 54.0332,58.2161C55.0532,56.9792 55.8995,55.1237 56.5723,52.6497L66.5007,52.6497C65.6326,57.8798 63.4136,62.1332 59.8438,65.4102C56.2739,68.6871 51.7003,70.3255 46.123,70.3255C39.222,70.3255 33.7967,68.112 29.847,63.6849C25.8974,59.2361 23.9225,53.138 23.9225,45.3906C23.9225,37.0139 26.1686,30.5577 30.6608,26.0221Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="D">
<circle cx="45" cy="45" r="45" style="fill:rgb(255,98,25);"/>
<path d="M35.7389,29.3424L35.7389,60.6576L44.9837,60.6576C49.7146,60.6576 53.0132,58.3247 54.8796,53.6589C55.8995,51.0981 56.4095,48.049 56.4095,44.5117C56.4095,39.6289 55.6445,35.88 54.1146,33.265C52.5846,30.65 49.541,29.3424 44.9837,29.3424L35.7389,29.3424ZM54.0983,22.0508C57.462,23.1576 60.1855,25.1866 62.2689,28.138C63.9399,30.5252 65.0792,33.1076 65.6868,35.8854C66.2945,38.6632 66.5983,41.3108 66.5983,43.8281C66.5983,50.2083 65.3179,55.612 62.7572,60.0391C59.2849,66.0069 53.9247,68.9909 46.6764,68.9909L26.0059,68.9909L26.0059,21.0091L46.6764,21.0091C49.6495,21.0525 52.1235,21.3997 54.0983,22.0508Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="E">
<circle cx="45" cy="45" r="45" style="fill:rgb(40,82,173);"/>
<path d="M63.3268,29.5052L37.9362,29.5052L37.9362,39.694L61.2435,39.694L61.2435,48.0273L37.9362,48.0273L37.9362,60.3646L64.4987,60.3646L64.4987,68.9909L28.138,68.9909L28.138,21.0091L63.3268,21.0091L63.3268,29.5052Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 761 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="F">
<circle cx="45" cy="45" r="45" style="fill:rgb(255,98,25);"/>
<path d="M29.7168,21.0742L63.7337,21.0742L63.7337,29.5052L39.6777,29.5052L39.6777,40.5404L60.7389,40.5404L60.7389,48.8737L39.6777,48.8737L39.6777,68.9909L29.7168,68.9909L29.7168,21.0742Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="G">
<circle cx="45" cy="45" r="45" style="fill:rgb(109,190,69);"/>
<path d="M56.4095,35.9831C55.65,32.7062 53.7945,30.4167 50.8431,29.1146C49.1938,28.3984 47.36,28.0404 45.3418,28.0404C41.4789,28.0404 38.3051,29.4998 35.8203,32.4186C33.3355,35.3375 32.0931,39.7266 32.0931,45.5859C32.0931,51.4887 33.4386,55.6662 36.1296,58.1185C38.8205,60.5707 41.8804,61.7969 45.3092,61.7969C48.673,61.7969 51.429,60.8257 53.5775,58.8835C55.7259,56.9412 57.0497,54.3967 57.5488,51.25L46.4486,51.25L46.4486,43.2422L66.4355,43.2422L66.4355,68.9909L59.7949,68.9909L58.7858,63.0013C56.8544,65.2799 55.1183,66.8859 53.5775,67.819C50.9299,69.4466 47.6747,70.2604 43.8118,70.2604C37.4533,70.2604 32.245,68.0577 28.1868,63.6523C23.9551,59.2253 21.8392,53.1706 21.8392,45.4883C21.8392,37.7192 23.9768,31.4909 28.252,26.8034C32.5271,22.1159 38.1803,19.7721 45.2116,19.7721C51.3097,19.7721 56.2088,21.3184 59.9089,24.4108C63.6089,27.5033 65.7303,31.3607 66.2728,35.9831L56.4095,35.9831Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g transform="matrix(1,0,0,1,-5,-5)">
<g id="H">
<g transform="matrix(1.02285,0,0,1.02285,-0.474267,-1.17657)">
<circle cx="49.347" cy="50.033" r="43.995" style="fill:rgb(40,82,173);"/>
</g>
<g transform="matrix(1.26802,0,0,1.26802,21.9029,39.5201)">
<path d="M7.127,27.185L7.127,-10.655L14.957,-10.655L14.957,3.772L29.744,3.772L29.744,-10.655L37.6,-10.655L37.6,27.185L29.744,27.185L29.744,10.293L14.957,10.293L14.957,27.185L7.127,27.185Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="J">
<circle cx="45" cy="45" r="45" style="fill:rgb(153,100,51);"/>
<path d="M37.3991,50.7943L37.3991,51.901C37.4859,55.612 37.8928,58.2216 38.6198,59.7298C39.3468,61.2381 40.9039,61.9922 43.291,61.9922C45.6565,61.9922 47.219,61.1675 47.9785,59.5182C48.4342,58.5417 48.6621,56.8924 48.6621,54.5703L48.6621,21.0091L58.6882,21.0091L58.6882,54.4076C58.6882,58.4874 57.9829,61.7209 56.5723,64.1081C54.1851,68.1445 49.8774,70.1628 43.6491,70.1628C37.4208,70.1628 33.2433,68.5297 31.1165,65.2637C28.9898,61.9976 27.9264,57.5434 27.9264,51.901L27.9264,50.7943L37.3991,50.7943Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="L">
<circle cx="45" cy="45" r="45" style="fill:rgb(167,169,172);"/>
<path d="M29.7168,21.0091L39.7428,21.0091L39.7428,60.3646L63.5059,60.3646L63.5059,68.9909L29.7168,68.9909L29.7168,21.0091Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 671 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="M">
<circle cx="45" cy="45" r="45" style="fill:rgb(255,98,25);"/>
<path d="M54.1146,21.0091L68.5352,21.0091L68.5352,68.9909L59.1927,68.9909L59.1927,36.5365C59.1927,35.6033 59.2036,34.2958 59.2253,32.6139C59.247,30.9321 59.2578,29.6354 59.2578,28.724L50.1758,68.9909L40.4427,68.9909L31.4258,28.724C31.4258,29.6354 31.4366,30.9321 31.4583,32.6139C31.48,34.2958 31.4909,35.6033 31.4909,36.5365L31.4909,68.9909L22.1484,68.9909L22.1484,21.0091L36.7318,21.0091L45.4557,58.737L54.1146,21.0091Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 967 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="N">
<circle cx="45" cy="45" r="45" style="fill:rgb(252,204,10);"/>
<path d="M25.8431,21.0091L36.3574,21.0091L55.4655,54.5052L55.4655,21.0091L64.8079,21.0091L64.8079,68.9909L54.7819,68.9909L35.1855,34.9089L35.1855,68.9909L25.8431,68.9909L25.8431,21.0091Z" style="fill:black;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="Q">
<circle cx="45" cy="45" r="45" style="fill:rgb(252,204,10);"/>
<path d="M48.597,61.4063C49.1829,61.2543 49.9316,60.9831 50.8431,60.5924L45.9928,55.9701L51.1686,50.5664L56.0189,55.1888C56.7784,53.6263 57.3101,52.2591 57.6139,51.0872C58.0914,49.3294 58.3301,47.2786 58.3301,44.9349C58.3301,39.553 57.2287,35.3917 55.026,32.4512C52.8234,29.5106 49.6061,28.0404 45.3743,28.0404C41.403,28.0404 38.2346,29.451 35.8691,32.2721C33.5037,35.0933 32.321,39.3142 32.321,44.9349C32.321,51.5104 34.0137,56.2196 37.3991,59.0625C39.5909,60.9071 42.2168,61.8294 45.2767,61.8294C46.4269,61.8294 47.5336,61.6884 48.597,61.4063ZM66.7936,54.9609C65.9473,57.717 64.6994,60.0065 63.0501,61.8294L68.584,67.0052L63.3431,72.474L57.5488,67.0052C55.791,68.0686 54.2719,68.8173 52.9915,69.2513C50.8431,69.9674 48.2715,70.3255 45.2767,70.3255C39.0267,70.3255 33.8618,68.4592 29.7819,64.7266C24.834,60.2344 22.36,53.6372 22.36,44.9349C22.36,36.1675 24.8991,29.5378 29.9772,25.0456C34.1222,21.378 39.2763,19.5443 45.4395,19.5443C51.6461,19.5443 56.8544,21.4865 61.0645,25.3711C65.9256,29.8633 68.3561,36.1458 68.3561,44.2188C68.3561,48.4939 67.8353,52.0747 66.7936,54.9609Z" style="fill:black;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="R">
<circle cx="45" cy="45" r="45" style="fill:rgb(252,204,10);"/>
<path d="M36.0319,29.3424L36.0319,42.2331L47.3926,42.2331C49.6495,42.2331 51.3422,41.9727 52.4707,41.4518C54.4672,40.5404 55.4655,38.7391 55.4655,36.0482C55.4655,33.1402 54.4998,31.1871 52.5684,30.1888C51.4833,29.6246 49.8557,29.3424 47.6855,29.3424L36.0319,29.3424ZM57.5326,22.2461C59.3446,23.0056 60.88,24.1233 62.1387,25.599C63.1803,26.8142 64.005,28.1597 64.6126,29.6354C65.2203,31.1111 65.5241,32.793 65.5241,34.681C65.5241,36.9596 64.949,39.2003 63.7988,41.403C62.6487,43.6057 60.7498,45.1628 58.1022,46.0742C60.3158,46.964 61.8837,48.2281 62.806,49.8665C63.7283,51.505 64.1895,54.0061 64.1895,57.3698L64.1895,60.5924C64.1895,62.7843 64.2763,64.2708 64.4499,65.0521C64.7103,66.2891 65.3179,67.2005 66.2728,67.7865L66.2728,68.9909L55.2376,68.9909C54.9338,67.9275 54.7168,67.0703 54.5866,66.4193C54.3262,65.0738 54.1851,63.6957 54.1634,62.2852L54.0983,57.8255C54.0549,54.7656 53.4961,52.7257 52.4219,51.7057C51.3477,50.6858 49.3349,50.1758 46.3835,50.1758L36.0319,50.1758L36.0319,68.9909L26.2337,68.9909L26.2337,21.0091L49.7689,21.0091C53.1326,21.0742 55.7205,21.4865 57.5326,22.2461Z" style="fill:black;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="S">
<circle cx="45" cy="45" r="45" style="fill:rgb(128,129,131);"/>
<path d="M35.1042,54.1797C35.408,56.3715 36.0048,58.01 36.8945,59.0951C38.5221,61.0699 41.3108,62.0573 45.2604,62.0573C47.6259,62.0573 49.5464,61.7969 51.0221,61.276C53.8216,60.2778 55.2214,58.4223 55.2214,55.7096C55.2214,54.1254 54.5269,52.8993 53.138,52.0313C51.7491,51.1849 49.5681,50.4362 46.5951,49.7852L41.5169,48.6458C36.5256,47.5174 33.0751,46.2912 31.1654,44.9674C27.9319,42.7539 26.3151,39.2925 26.3151,34.5833C26.3151,30.2865 27.8776,26.7166 31.0026,23.8737C34.1276,21.0308 38.7174,19.6094 44.7721,19.6094C49.8286,19.6094 54.1417,20.9494 57.7116,23.6296C61.2815,26.3097 63.1532,30.1997 63.3268,35.2995L53.6914,35.2995C53.5178,32.4132 52.2591,30.3624 49.9154,29.1471C48.3529,28.3442 46.4106,27.9427 44.0885,27.9427C41.5061,27.9427 39.4444,28.4635 37.9036,29.5052C36.3628,30.5469 35.5924,32.0009 35.5924,33.8672C35.5924,35.5816 36.352,36.862 37.8711,37.7083C38.8477,38.2726 40.931,38.9345 44.1211,39.694L52.3893,41.6797C56.0135,42.5477 58.7478,43.7088 60.5924,45.1628C63.457,47.4197 64.8893,50.6858 64.8893,54.9609C64.8893,59.3446 63.2129,62.985 59.86,65.8822C56.5072,68.7793 51.7708,70.2279 45.651,70.2279C39.401,70.2279 34.4857,68.801 30.9049,65.9473C27.3242,63.0935 25.5339,59.171 25.5339,54.1797L35.1042,54.1797Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="SF"><circle cx="45" cy="45" r="45" style="fill:#808183;"/><path d="M54.545,19.647l19.559,0l0,4.848l-13.832,0l0,6.345l12.11,0l0,4.792l-12.11,0l0,11.567l-5.727,0l0,-27.552Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M22.003,54.18c0.304,2.192 0.901,3.83 1.791,4.915c1.627,1.975 4.416,2.962 8.366,2.962c2.365,0 4.286,-0.26 5.761,-0.781c2.8,-0.998 4.2,-2.854 4.2,-5.566c0,-1.585 -0.695,-2.811 -2.084,-3.679c-1.389,-0.846 -3.57,-1.595 -6.543,-2.246l-5.078,-1.139c-4.991,-1.129 -8.442,-2.355 -10.351,-3.679c-3.234,-2.213 -4.851,-5.674 -4.851,-10.384c0,-4.297 1.563,-7.866 4.688,-10.709c3.125,-2.843 7.715,-4.265 13.769,-4.265c5.057,0 9.37,1.34 12.94,4.021c3.57,2.68 5.441,6.57 5.615,11.669l-9.635,0c-0.174,-2.886 -1.433,-4.937 -3.776,-6.152c-1.563,-0.803 -3.505,-1.204 -5.827,-1.204c-2.583,0 -4.644,0.521 -6.185,1.562c-1.541,1.042 -2.311,2.496 -2.311,4.362c0,1.715 0.759,2.995 2.278,3.841c0.977,0.565 3.06,1.226 6.25,1.986l8.268,1.986c3.625,0.868 6.359,2.029 8.204,3.483c2.864,2.257 4.296,5.523 4.296,9.798c0,4.384 -1.676,8.024 -5.029,10.921c-3.353,2.897 -8.089,4.346 -14.209,4.346c-6.25,0 -11.165,-1.427 -14.746,-4.281c-3.581,-2.853 -5.371,-6.776 -5.371,-11.767l9.57,0Z" style="fill:#fff;fill-rule:nonzero;"/></g></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="SIR"><circle cx="45" cy="45" r="45" style="fill:#0078c6;"/><g><path d="M15.369,52.053c0.217,1.559 0.641,2.725 1.274,3.497c1.158,1.405 3.142,2.107 5.952,2.107c1.683,0 3.049,-0.185 4.099,-0.556c1.992,-0.71 2.988,-2.03 2.988,-3.96c0,-1.127 -0.494,-1.999 -1.482,-2.617c-0.989,-0.602 -2.54,-1.135 -4.655,-1.598l-3.613,-0.81c-3.551,-0.803 -6.006,-1.676 -7.365,-2.617c-2.3,-1.575 -3.45,-4.038 -3.45,-7.388c0,-3.057 1.111,-5.597 3.334,-7.619c2.224,-2.023 5.489,-3.034 9.797,-3.034c3.597,0 6.666,0.953 9.205,2.86c2.54,1.907 3.872,4.674 3.995,8.302l-6.855,0c-0.123,-2.053 -1.019,-3.512 -2.686,-4.377c-1.112,-0.571 -2.494,-0.857 -4.146,-0.857c-1.837,0 -3.304,0.371 -4.4,1.112c-1.096,0.741 -1.644,1.776 -1.644,3.103c0,1.22 0.54,2.131 1.621,2.733c0.695,0.402 2.177,0.872 4.447,1.413l5.882,1.413c2.578,0.617 4.524,1.443 5.836,2.478c2.038,1.605 3.057,3.929 3.057,6.97c0,3.119 -1.193,5.709 -3.578,7.77c-2.385,2.061 -5.755,3.092 -10.109,3.092c-4.447,0 -7.944,-1.015 -10.491,-3.045c-2.548,-2.031 -3.821,-4.821 -3.821,-8.372l6.808,0Z" style="fill:#fff;fill-rule:nonzero;"/><rect x="40.159" y="28.454" width="7.087" height="34.137" style="fill:#fff;fill-rule:nonzero;"/><path d="M59.9,34.382l0,9.171l8.083,0c1.605,0 2.81,-0.185 3.612,-0.556c1.421,-0.648 2.131,-1.93 2.131,-3.844c0,-2.069 -0.687,-3.458 -2.061,-4.169c-0.772,-0.401 -1.93,-0.602 -3.474,-0.602l-8.291,0Zm15.297,-5.048c1.289,0.54 2.381,1.335 3.277,2.385c0.741,0.865 1.327,1.822 1.76,2.872c0.432,1.05 0.648,2.246 0.648,3.589c0,1.621 -0.409,3.216 -1.227,4.783c-0.819,1.567 -2.169,2.675 -4.053,3.323c1.575,0.633 2.69,1.532 3.346,2.698c0.657,1.166 0.985,2.945 0.985,5.338l0,2.293c0,1.559 0.061,2.617 0.185,3.173c0.185,0.88 0.618,1.528 1.297,1.945l0,0.857l-7.851,0c-0.216,-0.757 -0.371,-1.366 -0.463,-1.83c-0.186,-0.957 -0.286,-1.937 -0.301,-2.941l-0.047,-3.173c-0.031,-2.177 -0.428,-3.628 -1.192,-4.353c-0.765,-0.726 -2.197,-1.089 -4.296,-1.089l-7.365,0l0,13.386l-6.971,0l0,-34.136l16.744,0c2.393,0.046 4.234,0.339 5.524,0.88Z" style="fill:#fff;fill-rule:nonzero;"/></g></g></svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="SR"><circle cx="45" cy="45" r="45" style="fill:#808183;"/><path d="M60.178,24.401l0,7.412l6.533,0c1.298,0 2.271,-0.15 2.92,-0.449c1.148,-0.524 1.722,-1.56 1.722,-3.107c0,-1.672 -0.555,-2.795 -1.666,-3.369c-0.624,-0.325 -1.56,-0.487 -2.808,-0.487l-6.701,0Zm7.899,-4.792c1.934,0.038 3.422,0.275 4.464,0.712c1.042,0.436 1.925,1.079 2.649,1.928c0.599,0.698 1.073,1.472 1.422,2.32c0.35,0.849 0.524,1.816 0.524,2.902c0,1.31 -0.33,2.598 -0.992,3.865c-0.661,1.266 -1.753,2.162 -3.275,2.686c1.273,0.511 2.174,1.238 2.705,2.18c0.53,0.943 0.795,2.381 0.795,4.315l0,1.853c0,1.26 0.05,2.115 0.15,2.564c0.15,0.711 0.499,1.235 1.048,1.572l0,0.693l-6.345,0c-0.175,-0.612 -0.3,-1.104 -0.375,-1.479c-0.149,-0.773 -0.23,-1.566 -0.243,-2.377l-0.037,-2.564c-0.025,-1.76 -0.347,-2.933 -0.964,-3.519c-0.618,-0.587 -1.775,-0.88 -3.472,-0.88l-5.953,0l0,10.819l-5.633,0l0,-27.59l13.532,0Z" style="fill:#fff;fill-rule:nonzero;"/><path d="M22.003,54.18c0.304,2.192 0.901,3.83 1.791,4.915c1.627,1.975 4.416,2.962 8.366,2.962c2.365,0 4.286,-0.26 5.761,-0.781c2.8,-0.998 4.2,-2.854 4.2,-5.566c0,-1.585 -0.695,-2.811 -2.084,-3.679c-1.389,-0.846 -3.57,-1.595 -6.543,-2.246l-5.078,-1.139c-4.991,-1.129 -8.442,-2.355 -10.351,-3.679c-3.234,-2.213 -4.851,-5.674 -4.851,-10.384c0,-4.297 1.563,-7.866 4.688,-10.709c3.125,-2.843 7.715,-4.265 13.769,-4.265c5.057,0 9.37,1.34 12.94,4.021c3.57,2.68 5.441,6.57 5.615,11.669l-9.635,0c-0.174,-2.886 -1.433,-4.937 -3.776,-6.152c-1.563,-0.803 -3.505,-1.204 -5.827,-1.204c-2.583,0 -4.644,0.521 -6.185,1.562c-1.541,1.042 -2.311,2.496 -2.311,4.362c0,1.715 0.759,2.995 2.278,3.841c0.977,0.565 3.06,1.226 6.25,1.986l8.268,1.986c3.625,0.868 6.359,2.029 8.204,3.483c2.864,2.257 4.296,5.523 4.296,9.798c0,4.384 -1.676,8.024 -5.029,10.921c-3.353,2.897 -8.089,4.346 -14.209,4.346c-6.25,0 -11.165,-1.427 -14.746,-4.281c-3.581,-2.853 -5.371,-6.776 -5.371,-11.767l9.57,0Z" style="fill:#fff;fill-rule:nonzero;"/></g></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g transform="matrix(1,0,0,1,-5,-5)">
<g id="T">
<g transform="matrix(1.02285,0,0,1.02285,-0.474267,-1.17657)">
<circle cx="49.347" cy="50.033" r="43.995" style="fill:rgb(0,174,239);"/>
</g>
<g transform="matrix(1.26802,0,0,1.26802,21.9029,39.5201)">
<path d="M37.6,-10.655L37.6,-3.955L26.278,-3.955L26.278,27.185L18.32,27.185L18.32,-3.955L6.948,-3.955L6.948,-10.655L37.6,-10.655Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 997 B

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g transform="matrix(1,0,0,1,-5,-5)">
<g id="W">
<g transform="matrix(1.02285,0,0,1.02285,-0.474267,-1.17657)">
<circle cx="49.347" cy="50.033" r="43.995" style="fill:rgb(252,204,10);"/>
</g>
<g transform="matrix(1.26802,0,0,1.26802,21.9029,39.5201)">
<path d="M6.511,-10.655L11.517,11.012L12.596,17.044L13.699,11.14L17.961,-10.655L26.304,-10.655L30.797,11.012L31.952,17.044L33.107,11.243L38.164,-10.655L46.2,-10.655L35.546,27.185L27.998,27.185L23.429,5.056L22.094,-2.261L20.759,5.056L16.19,27.185L8.847,27.185L-1.883,-10.655L6.511,-10.655Z" style="fill:black;fill-rule:nonzero;"/>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 90 90" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421;">
<g id="Z">
<circle cx="45" cy="45" r="45" style="fill:rgb(153,100,51);"/>
<path d="M26.3314,60.5273L51.1035,29.5052L26.9499,29.5052L26.9499,21.0091L63.6035,21.0091L63.6035,29.0495L38.5059,60.5273L63.6686,60.5273L63.6686,68.9909L26.3314,68.9909L26.3314,60.5273Z" style="fill:white;fill-rule:nonzero;"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 283 64"><path fill="black" d="M141 16c-11 0-19 7-19 18s9 18 20 18c7 0 13-3 16-7l-7-5c-2 3-6 4-9 4-5 0-9-3-10-7h28v-3c0-11-8-18-19-18zm-9 15c1-4 4-7 9-7s8 3 9 7h-18zm117-15c-11 0-19 7-19 18s9 18 20 18c6 0 12-3 16-7l-8-5c-2 3-5 4-8 4-5 0-9-3-11-7h28l1-3c0-11-8-18-19-18zm-10 15c2-4 5-7 10-7s8 3 9 7h-19zm-39 3c0 6 4 10 10 10 4 0 7-2 9-5l8 5c-3 5-9 8-17 8-11 0-19-7-19-18s8-18 19-18c8 0 14 3 17 8l-8 5c-2-3-5-5-9-5-6 0-10 4-10 10zm83-29v46h-9V5h9zM37 0l37 64H0L37 0zm92 5-27 48L74 5h10l18 30 17-30h10zm59 12v10l-3-1c-6 0-10 4-10 10v15h-9V17h9v9c0-5 6-9 13-9z"/></svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,18 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./mta_sign_server/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
backgroundImage: {
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
'gradient-conic':
'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
},
},
},
plugins: [],
}

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { AppConfig, StationConfig, CONFIG_VERSION } from '@/types/config'
describe('Config Types', () => {
it('CONFIG_VERSION is defined', () => {
expect(CONFIG_VERSION).toBeDefined()
expect(typeof CONFIG_VERSION).toBe('number')
})
it('StationConfig has correct shape', () => {
const config: StationConfig = {
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: false,
selectedLines: ['A', 'C', 'E'],
}
expect(config.stationId).toBe('127')
expect(config.stationName).toBe('Times Sq-42 St')
expect(config.showNorth).toBe(true)
expect(config.showSouth).toBe(false)
expect(config.selectedLines).toEqual(['A', 'C', 'E'])
})
it('AppConfig has correct shape', () => {
const appConfig: AppConfig = {
version: CONFIG_VERSION,
stations: [
{
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: true,
selectedLines: ['A', 'C', 'E'],
},
],
}
expect(appConfig.version).toBe(CONFIG_VERSION)
expect(appConfig.stations).toHaveLength(1)
expect(appConfig.stations[0].stationId).toBe('127')
})
it('AppConfig can serialize to JSON and back', () => {
const appConfig: AppConfig = {
version: CONFIG_VERSION,
stations: [
{
stationId: '127',
stationName: 'Times Sq-42 St',
showNorth: true,
showSouth: false,
selectedLines: ['1', '2', '3'],
},
{
stationId: 'A27',
stationName: '42 St-Port Authority',
showNorth: false,
showSouth: true,
selectedLines: ['A', 'C', 'E'],
},
],
}
const json = JSON.stringify(appConfig)
const parsed = JSON.parse(json) as AppConfig
expect(parsed.version).toBe(appConfig.version)
expect(parsed.stations).toHaveLength(2)
expect(parsed.stations[0]).toEqual(appConfig.stations[0])
expect(parsed.stations[1]).toEqual(appConfig.stations[1])
})
})

View File

@@ -0,0 +1 @@
import '@testing-library/react'

42
mta-sign-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,42 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}

View File

@@ -0,0 +1,15 @@
export interface StationConfig {
id: string
stationId: string
stationName: string
showNorth: boolean
showSouth: boolean
selectedLines: string[]
}
export interface AppConfig {
version: number
stations: StationConfig[]
}
export const CONFIG_VERSION = 1

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './'),
},
},
})

View File

@@ -0,0 +1,5 @@
from .mta import MTA
from .train import Train
from .feed import Feed
from .route import Route

17
mta_api_client/feed.py Normal file
View File

@@ -0,0 +1,17 @@
from enum import Enum
class Feed(Enum):
ACE = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-ace"
BDFM = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm"
G = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-bdfm"
JZ = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-jz"
NQRW = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-nqrw"
L = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-l"
N1234567 = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs"
SIR = "https://api-endpoint.mta.info/Dataservice/mtagtfsfeeds/nyct%2Fgtfs-si"
ALL_FEEDS = [
member for member in Feed
]

50
mta_api_client/mta.py Normal file
View File

@@ -0,0 +1,50 @@
import requests
from google.transit import gtfs_realtime_pb2
from .train import Train
from .feed import Feed, ALL_FEEDS
from .route import Route
class MTA(object):
def __init__(self, api_key: str, feeds: [Feed] = ALL_FEEDS, stations: [str] = [],
max_arrival_time: int = 30):
self.header = {
"x-api-key": api_key
}
self.feeds = feeds
self.stations = stations
self.max_arrival_time = max_arrival_time
self.trains: [Train] = []
def stop_updates(self):
self.is_running = False
def update_trains(self) -> [Train]:
trains = []
for feed in self.feeds:
r = requests.get(feed.value, headers=self.header)
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(r.content)
trains.extend([train for train in [Train(train) for train in feed.entity] if
train.has_trips()])
self.trains = trains
return trains
def get_trains(self) -> [Train]:
return self.trains
def get_arrival_times(self, route: Route, station: str) -> [int]:
arrival_times = []
for train in self.trains:
if train.get_route() is route:
arrival = train.get_arrival_at(station)
if arrival is not None and arrival < self.max_arrival_time and arrival > 0:
arrival_times.append(arrival)
return sorted(arrival_times)
def add_station_id(self, station_id: str):
self.stations.append(station_id)
def remove_station_id(self, station_id: str):
self.stations.remove(station_id)

40
mta_api_client/route.py Normal file
View File

@@ -0,0 +1,40 @@
from enum import Enum
class Route(Enum):
A = "A"
C = "C"
E = "E"
B = "B"
D = "D"
F = "F"
M = "M"
G = "G"
J = "J"
Z = "Z"
N = "N"
Q = "Q"
R = "R"
W = "W"
N1 = "1"
N2 = "2"
N3 = "3"
N4 = "4"
N5 = "5"
N6 = "6"
N7 = "7"
L = "L"
SIR = "SIR"
_routes = set(item.value for item in Route)
def is_valid_route(route: str) -> bool:
return route in _routes

7
mta_api_client/stop.py Normal file
View File

@@ -0,0 +1,7 @@
from datetime import datetime
from google.transit import gtfs_realtime_pb2
from math import trunc
def trip_arrival_in_minutes(stop_time_update: gtfs_realtime_pb2.TripUpdate):
return trunc(((datetime.fromtimestamp(stop_time_update.arrival.time) - datetime.now()).total_seconds()) / 60)

33
mta_api_client/train.py Normal file
View File

@@ -0,0 +1,33 @@
from google.transit import gtfs_realtime_pb2
from .stop import trip_arrival_in_minutes
from .route import Route, is_valid_route
class Train(object):
def __init__(self, train_proto: gtfs_realtime_pb2.FeedEntity):
self.train_proto: gtfs_realtime_pb2.FeedEntity = train_proto
def get_arrival_at(self, stop_id) -> int | None:
"""
returns the routes stop time at a given stop ID in minutes
if not found, returns None
:param stop_id: stop ID of arrival station
:return: arrival time in minutes
"""
for stop_time_update in self.train_proto.trip_update.stop_time_update:
if stop_time_update.stop_id == stop_id:
return trip_arrival_in_minutes(stop_time_update)
return None
def _get_route(self) -> str:
return self.train_proto.trip_update.trip.route_id
def get_route(self) -> Route:
return Route(self.train_proto.trip_update.trip.route_id)
def has_trips(self) -> bool:
return self.train_proto.trip_update is not None \
and len(self.train_proto.trip_update.stop_time_update) > 0 and is_valid_route(self._get_route())
def __str__(self):
return f"{self.train_proto}"

View File

@@ -1,115 +0,0 @@
import asyncio
import requests
import json
from google.transit import gtfs_realtime_pb2
from protobuf_to_dict import protobuf_to_dict
from time import time
from .train import Train
class MTA(object):
# Create a data filter object.
# Then be able to update that object on the fly.
# This filter should return all possible trains and stations by default.
# If anything is added it gets filtered out.
def __init__(self, api_key: str, routes, station_ids, timing_callbacks=None, alert_callbacks=None,
endpoints_file="./endpoints.json", callback_frequency=10, max_arrival_time=30):
self.header = {
"x-api-key": api_key
}
self.routes = routes
self.station_ids = station_ids
self.timing_callbacks = timing_callbacks if timing_callbacks else []
self.is_running = False
self.callback_frequency = callback_frequency
self.max_arrival_time = max_arrival_time
with open(endpoints_file, "r") as f:
self.endpoints = json.load(f)
self.set_valid_endpoints()
def set_valid_endpoints(self):
self.valid_endpoints = {}
for key, value in self.endpoints.items():
valid_routes = [x for x in self.routes if x in key]
if valid_routes:
self.valid_endpoints[value] = valid_routes
print(self.valid_endpoints)
def start_updates(self):
print("starting updates")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(self._get_updates())
def stop_updates(self):
self.is_running = False
async def get_data(self):
trains = []
for endpoint, valid_lines in self.valid_endpoints.items():
r = requests.get(endpoint, headers=self.header)
feed = gtfs_realtime_pb2.FeedMessage()
feed.ParseFromString(r.content)
subway_feed = protobuf_to_dict(feed)['entity']
trains.extend([train for train in [Train.get_train_from_dict(train_dict) for train_dict in subway_feed] if
train is not None])
return trains
@staticmethod
def get_trains_for_routes(routes, trains):
return [train for train in trains if train.route in routes]
@staticmethod
def get_trains_for_route(route, trains):
return MTA.get_trains_for_routes([route], trains)
async def get_train_information(self):
valid_trains = [train for train in await self.get_data() if True]
return valid_trains
async def _get_updates(self):
self.is_running = True
while (self.is_running):
t = time()
data = self.get_train_information()
data = await data
await self.process_callbacks(data)
await asyncio.sleep(self.callback_frequency - (time() - t))
async def process_callbacks(self, data):
for callback in self.timing_callbacks:
await callback(data)
def add_train_line(self, train_line: str):
self.routes.append(train_line)
self.set_valid_endpoints()
def remove_train_line(self, train_line: str):
self.routes.remove(train_line)
self.set_valid_endpoints()
def add_station_id(self, station_id: str):
self.station_ids.append(station_id)
def remove_station_id(self, station_id: str):
self.station_ids.remove(station_id)
def add_callback(self, callback_func):
self.timing_callbacks.append(callback_func)
def remove_callback(self, callback_func):
self.timing_callbacks.remove(callback_func)
def get_time_arriving_at_stations(self, trains):
station_first = {}
for station_id in self.station_ids:
line_first = {}
for route in self.routes:
valid_trains = [train.get_arrival_at(station_id) for train in MTA.get_trains_for_route(route, trains) if
train.arriving_at_station_in_time(station_id, self.max_arrival_time)]
if valid_trains:
line_first[route] = valid_trains
if line_first:
station_first[station_id] = line_first
return station_first

View File

@@ -1,24 +0,0 @@
from datetime import datetime
from math import trunc
class Stop(object):
def __init__(self, id, arrival_time, departure_time, ):
self.id = id
self.arrival_time = arrival_time
self.departure_time = departure_time
def arrival_minutes(self):
return trunc(((datetime.fromtimestamp(self.arrival_time) - datetime.now()).total_seconds()) / 60)
def __str__(self):
now = datetime.now()
time = datetime.fromtimestamp(self.arrival_time)
time_minutes = trunc(((time - now).total_seconds()) / 60)
return f"stop_id:{self.id}| arr:{time_minutes}| dep:{self.departure_time}"
@staticmethod
def get_stop_from_dict(obj):
if "arrival" in obj and "departure" in obj and "stop_id" in obj:
return Stop(obj["stop_id"], obj["arrival"]["time"], obj["departure"]["time"])
return None

View File

@@ -1,42 +0,0 @@
from .stop import Stop
class Train(object):
def __init__(self, id, route, stops):
self.id = id
self.route = route
self.stops = stops
def get_arrival_at(self, stop_id):
"""
returns the routes stop time at a given stop ID in minutes
if not found, returns None
:param stop_id: stop ID of arrival station
:return: arrival time in minutes
"""
for stop in self.stops:
if stop.id == stop_id:
return stop.arrival_minutes()
return None
def arriving_at_station_in_time(self, station_id, max_time):
for stop in self.stops:
minutes_to_arrival = stop.arrival_minutes()
if stop.id == station_id:
if minutes_to_arrival > 0 and minutes_to_arrival < max_time:
return True
def __str__(self):
formatted_stops = '\n'.join([str(stop) for stop in self.stops])
return f"train_id:{self.id} | line_name:{self.route}| stops:\n {formatted_stops}"
@staticmethod
def get_train_from_dict(obj):
if "trip_update" in obj and "stop_time_update" in obj["trip_update"]:
# data we need is here create object
id = obj["id"]
route = obj["trip_update"]["trip"]["route_id"]
all_stops = [Stop.get_stop_from_dict(x) for x in obj["trip_update"]["stop_time_update"]]
valid_stops = [valid_stop for valid_stop in all_stops if valid_stop is not None]
return Train(id, route, valid_stops)
else:
return None

View File

View File

@@ -0,0 +1,56 @@
import csv
import logging
from pathlib import Path
from fastapi import APIRouter
from mta_api_client import Route
from mta_sign_server.config.schemas import Station, StationsResponse, LinesResponse
router = APIRouter(
tags=["config"],
)
logger = logging.getLogger("config_router")
STOPS_FILE = Path(__file__).parent.parent.parent / "stops.txt"
@router.get("/api/config")
def get_all():
return {"config": "goes here"}
@router.get("/api/stations", response_model=StationsResponse)
def get_stations(search: str | None = None):
"""Get list of all stations, optionally filtered by search term. Deduplicates by station name."""
stations_dict = {}
if STOPS_FILE.exists():
with open(STOPS_FILE, "r") as f:
reader = csv.DictReader(f)
for row in reader:
# Only include parent stations (location_type == 1)
if row.get("location_type") == "1":
station_name = row["stop_name"]
station_id = row["stop_id"]
# Only add first occurrence of each station name to deduplicate
if station_name not in stations_dict:
station = Station(id=station_id, name=station_name)
# Filter by search term if provided
if search:
if search.lower() in station.name.lower() or search in station.id:
stations_dict[station_name] = station
else:
stations_dict[station_name] = station
return StationsResponse(stations=list(stations_dict.values()))
@router.get("/api/lines", response_model=LinesResponse)
def get_lines():
"""Get list of all available train lines."""
lines = [route.value for route in Route]
return LinesResponse(lines=sorted(lines))

View File

@@ -0,0 +1,14 @@
from pydantic import BaseModel
class Station(BaseModel):
id: str
name: str
class StationsResponse(BaseModel):
stations: list[Station]
class LinesResponse(BaseModel):
lines: list[str]

View File

View File

@@ -0,0 +1,134 @@
import csv
import logging
import os
from pathlib import Path
from collections import defaultdict
from fastapi import APIRouter, HTTPException
from fastapi_utils.tasks import repeat_every
from starlette import status
from mta_api_client import Route, MTA
from mta_api_client.feed import ALL_FEEDS
from mta_sign_server.mta.schemas import StationResponse, RouteResponse, AllStationResponse
router = APIRouter(
tags=["mta-data"],
)
logger = logging.getLogger("mta")
api_key = os.getenv('MTA_API_KEY', '')
mtaController = MTA(
api_key,
feeds=ALL_FEEDS
)
ROUTES = [member for member in Route]
STATION_STOP_IDs = ["127S", "127N", "A27N", "A27S"]
STOPS_FILE = Path(__file__).parent.parent.parent / "stops.txt"
# Build mappings for station resolution
_station_name_to_ids = None
_station_id_to_name = None
def _load_station_mapping():
global _station_name_to_ids, _station_id_to_name
if _station_name_to_ids is not None:
return
_station_name_to_ids = defaultdict(set)
_station_id_to_name = {}
if STOPS_FILE.exists():
with open(STOPS_FILE, "r") as f:
reader = csv.DictReader(f)
for row in reader:
if row.get("location_type") == "1": # Parent stations only
station_name = row["stop_name"]
station_id = row["stop_id"]
_station_name_to_ids[station_name].add(station_id)
_station_id_to_name[station_id] = station_name
def _resolve_base_station_ids(stop_id: str) -> list[str]:
"""Resolve a stop_id to a list of all base parent station IDs (without direction) that share the same station name."""
_load_station_mapping()
# Remove direction suffix to get base ID
base_id = stop_id.rstrip("NS")
# Look up the station name for this ID
if base_id in _station_id_to_name:
station_name = _station_id_to_name[base_id]
# Return all base station IDs with this name
return sorted(list(_station_name_to_ids[station_name]))
# If not found, just return the base ID
return [base_id]
@router.post("/api/mta/{stop_id}/{route}", response_model=RouteResponse, status_code=status.HTTP_200_OK)
def get_route(stop_id: str, route: Route):
arrival_times = mtaController.get_arrival_times(route, stop_id)
if len(arrival_times) > 0:
return RouteResponse(routeId=route, arrival_times=arrival_times)
raise HTTPException(status_code=404, detail="no stops found for route and stop id")
@router.post("/api/mta/{stop_id}", response_model=StationResponse, status_code=status.HTTP_200_OK)
def get_station(stop_id: str):
routes_dict = {} # Use dict to avoid duplicates
base_station_ids = _resolve_base_station_ids(stop_id)
# Extract the requested direction
requested_direction = ""
if stop_id.endswith("N"):
requested_direction = "N"
elif stop_id.endswith("S"):
requested_direction = "S"
# Only query the requested direction for each base station ID
for base_id in base_station_ids:
station_id_with_direction = f"{base_id}{requested_direction}"
for route in ROUTES:
arrival_times = mtaController.get_arrival_times(route, station_id_with_direction)
if len(arrival_times) > 0:
route_key = route.value
if route_key not in routes_dict:
routes_dict[route_key] = (route, arrival_times)
else:
# Combine arrival times from different station IDs
routes_dict[route_key] = (route, sorted(list(set(routes_dict[route_key][1] + arrival_times))))
routes = [RouteResponse(routeId=route, arrival_times=times) for route, times in routes_dict.values()]
if routes:
return StationResponse(stationId=stop_id, routes=routes)
raise HTTPException(status_code=404, detail="no trains or routes found for stop id")
@router.post("/api/mta", response_model=AllStationResponse, status_code=status.HTTP_200_OK)
def get_all():
print("HELLO WORLD")
all_stations = []
for stop_id in STATION_STOP_IDs:
routes = []
for route in ROUTES:
arrival_times = mtaController.get_arrival_times(route, stop_id)
if len(arrival_times) > 0:
routes.append(RouteResponse(routeId=route, arrival_times=arrival_times))
all_stations.append(StationResponse(stationId=stop_id, routes=routes))
print(all_stations)
if all_stations:
return AllStationResponse(stations=all_stations)
raise HTTPException(status_code=404, detail="no arriving trains found for all configured routes")
@router.on_event("startup")
@repeat_every(seconds=10)
def update_trains():
logger.info("UPDATING TRAINS")
mtaController.update_trains()

View File

@@ -0,0 +1,18 @@
from pydantic import BaseModel
from typing import List
from mta_api_client import Route
class RouteResponse(BaseModel):
routeId: Route
arrival_times: List[int]
class StationResponse(BaseModel):
stationId: str
routes: List[RouteResponse]
class AllStationResponse(BaseModel):
stations: List[StationResponse]

12
mta_sign_server/router.py Normal file
View File

@@ -0,0 +1,12 @@
from datetime import datetime
from fastapi import APIRouter, status
router = APIRouter(
tags=["start"],
)
start_time = datetime.now()
@router.post("/api/start_time", status_code=status.HTTP_200_OK)
def get_start_time():
return start_time.isoformat()

View File

@@ -1,62 +0,0 @@
import os
from dotenv import load_dotenv
from mta_manager import MTA
import threading
from time import sleep
from pprint import pprint
load_dotenv()
api_key = os.getenv('MTA_API_KEY', '')
mtaController = MTA(
api_key,
["A", "C", "E", "1", "2", "3"],
["127S", "127N", "A27N", "A27S"]
)
async def mta_callback(trains):
print("We are inside of the call back now")
print(len(trains))
pprint([str(route) for route in trains])
pprint(mtaController.get_time_arriving_at_stations(trains))
class Threadwrapper(threading.Thread):
def __init__(self, run):
threading.Thread.__init__(self)
self.run = run
def run(self):
self.run()
def start_mta():
mtaController.add_callback(mta_callback)
mtaController.start_updates()
def stop_mta():
sleep(10)
mtaController.stop_updates()
threadLock = threading.Lock()
threads = []
# Create new threads
thread1 = Threadwrapper(start_mta)
thread2 = Threadwrapper(stop_mta)
thread1.start()
thread2.start()
# Add threads to thread list
threads.append(thread1)
threads.append(thread2)
# Wait for all threads to complete
for t in threads:
t.join()
print("Exiting Main Thread")

10
node_modules/.yarn-integrity generated vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"systemParams": "linux-x64-115",
"modulesFolders": [],
"flags": [],
"linkedModules": [],
"topLevelPatterns": [],
"lockfileEntries": {},
"files": [],
"artifacts": {}
}

3
package.json Normal file
View File

@@ -0,0 +1,3 @@
{
"packageManager": "yarn@4.1.0"
}

Some files were not shown because too many files have changed in this diff Show More