diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..445d44c29 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,72 @@ +# Git +.git +.gitignore +.github + +# Python +__pycache__ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +.venv +*.egg-info/ +.eggs/ +dist/ +build/ +.pytest_cache/ +.python-version + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.yarn +client/node_modules/ +client/dist/ +client/.vite/ +test-client/node_modules/ +test-client/dist/ + +# Environment +.env +.env.local +.env.*.local +.flaskenv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +*.sqlite3 +instance/ + +# Testing +.coverage +htmlcov/ +.tox/ + +# Documentation +*.md +!README.md + +# Misc +*.bak +*.tmp +.yamllint diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..1a4939eb9 --- /dev/null +++ b/.env.example @@ -0,0 +1,62 @@ +# ============================================================================= +# SHUBBLE ENVIRONMENT CONFIGURATION +# ============================================================================= +# Copy this file to .env and update with your values + +# ============================================================================= +# SERVICE PORTS (Docker) +# ============================================================================= +# Configure which ports services are exposed on the host machine +FRONTEND_PORT=3000 +BACKEND_PORT=8000 +POSTGRES_PORT=5432 +REDIS_PORT=6379 +TEST_FRONTEND_PORT=5174 +TEST_BACKEND_PORT=4000 + +# ============================================================================= +# SERVICE URLS +# ============================================================================= +# Configure URLs for all services +# Format: http://host:port (do not include trailing slash) + +# Main application URLs +FRONTEND_URL=http://localhost:3000 +VITE_FRONTEND_URL=http://localhost:3000 +VITE_BACKEND_URL=http://localhost:8000 + +# Test/Mock service URLs (for development/testing) +TEST_FRONTEND_URL=http://localhost:5174 +VITE_TEST_FRONTEND_URL=http://localhost:5174 +VITE_TEST_BACKEND_URL=http://localhost:4000 + +# ============================================================================= +# DATABASE +# ============================================================================= +# PostgreSQL credentials +POSTGRES_DB=shubble +POSTGRES_USER=shubble +POSTGRES_PASSWORD=shubble + +# PostgreSQL connection string +DATABASE_URL=postgresql://shubble:shubble@localhost:5432/shubble + +# ============================================================================= +# REDIS CACHE +# ============================================================================= +# Redis connection string +REDIS_URL=redis://localhost:6379/0 + +# ============================================================================= +# FASTAPI CONFIGURATION +# ============================================================================= +ENV=development +DEBUG=true +LOG_LEVEL=info + +# ============================================================================= +# SAMSARA API (Optional - for production) +# ============================================================================= +# Leave empty to use Mock Samsara API (test-server) in development +API_KEY= +SAMSARA_SECRET_BASE64= diff --git a/.env.prod.example b/.env.prod.example new file mode 100644 index 000000000..6fceaeb48 --- /dev/null +++ b/.env.prod.example @@ -0,0 +1,27 @@ +# postgres +POSTGRES_DB=shubble +POSTGRES_USER=shubble +POSTGRES_PASSWORD=shubble +POSTGRES_PORT=5432 + +# python env variable +DATABASE_URL=postgresql://shubble:shubble@postgres:5432/shubble +DEBUG=false +LOG_LEVEL=info + +# Backend Docker +FRONTEND_URL=http://localhost:3000 +BACKEND_PORT=8000 + +# Secrets +API_KEY= +SAMSARA_SECRET= + +# redis +REDIS_PORT=6379 +REDIS_URL=redis://redis:6379/0 + +# Frontend Docker +FRONTEND_PORT=3000 +# Vite +VITE_BACKEND_URL=http://localhost:8000 diff --git a/.flaskenv b/.flaskenv deleted file mode 100644 index 9c8e6d288..000000000 --- a/.flaskenv +++ /dev/null @@ -1,2 +0,0 @@ -FLASK_APP=server:create_app -FLASK_ENV=development diff --git a/.gitignore b/.gitignore index 9330de377..6a07fd899 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ -__pycache__/ +**__pycache__/ node_modules/ .env client/dist diff --git a/.python-version b/.python-version deleted file mode 100644 index 24ee5b1be..000000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.13 diff --git a/Procfile b/Procfile deleted file mode 100644 index 46620a969..000000000 --- a/Procfile +++ /dev/null @@ -1,3 +0,0 @@ -release: flask --app server:create_app db upgrade -web: gunicorn shubble:app --bind 0.0.0.0:$PORT --log-level $LOG_LEVEL -worker: python -m server.worker diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 000000000..729193155 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,142 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library. +# Any required deps can installed by adding `alembic[tz]` to the pip requirements +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +# sqlalchemy.url = driver://user:pass@localhost/dbname +# Note: Database URL is loaded from .env file in env.py + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the exec runner, execute a binary +# hooks = ruff +# ruff.type = exec +# ruff.executable = %(here)s/.venv/bin/ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/README b/alembic/README new file mode 100644 index 000000000..98e4f9c44 --- /dev/null +++ b/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 000000000..dc57a649b --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,106 @@ +"""Alembic environment configuration for async SQLAlchemy.""" +import asyncio +from logging.config import fileConfig + +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from alembic import context + +# Load application config to get DATABASE_URL +from server.config import settings +from server.database import Base + +# Import all models to ensure they're registered with Base +from server.models import ( + Vehicle, + GeofenceEvent, + VehicleLocation, + Driver, + DriverVehicleAssignment, +) + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Set database URL from settings +database_url = settings.DATABASE_URL +if database_url.startswith("postgresql://"): + database_url = database_url.replace("postgresql://", "postgresql+asyncpg://") +elif database_url.startswith("postgres://"): + database_url = database_url.replace("postgres://", "postgresql+asyncpg://") + +config.set_main_option("sqlalchemy.url", database_url) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """Run migrations with the given connection.""" + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async support.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 000000000..480b130d6 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/4f42c8d834fa_initial.py b/alembic/versions/4f42c8d834fa_initial.py new file mode 100644 index 000000000..667fd0cda --- /dev/null +++ b/alembic/versions/4f42c8d834fa_initial.py @@ -0,0 +1,99 @@ +"""initial + +Revision ID: 4f42c8d834fa +Revises: +Create Date: 2025-12-24 23:11:50.160351 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '4f42c8d834fa' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('drivers', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('vehicles', + sa.Column('id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=False), + sa.Column('asset_type', sa.String(), nullable=False), + sa.Column('license_plate', sa.String(), nullable=True), + sa.Column('vin', sa.String(), nullable=True), + sa.Column('maintenance_id', sa.String(), nullable=True), + sa.Column('gateway_model', sa.String(), nullable=True), + sa.Column('gateway_serial', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('driver_vehicle_assignments', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('driver_id', sa.String(), nullable=False), + sa.Column('vehicle_id', sa.String(), nullable=False), + sa.Column('assignment_start', sa.DateTime(timezone=True), nullable=False), + sa.Column('assignment_end', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['driver_id'], ['drivers.id'], ), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_driver_vehicle_assignments_driver_id'), 'driver_vehicle_assignments', ['driver_id'], unique=False) + op.create_index(op.f('ix_driver_vehicle_assignments_vehicle_id'), 'driver_vehicle_assignments', ['vehicle_id'], unique=False) + op.create_table('geofence_events', + sa.Column('id', sa.String(), nullable=False), + sa.Column('vehicle_id', sa.String(), nullable=False), + sa.Column('event_type', sa.String(), nullable=False), + sa.Column('event_time', sa.DateTime(timezone=True), nullable=False), + sa.Column('address_name', sa.String(), nullable=True), + sa.Column('address_formatted', sa.String(), nullable=True), + sa.Column('latitude', sa.Float(), nullable=True), + sa.Column('longitude', sa.Float(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('vehicle_locations', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('vehicle_id', sa.String(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('timestamp', sa.DateTime(timezone=True), nullable=False), + sa.Column('latitude', sa.Float(), nullable=False), + sa.Column('longitude', sa.Float(), nullable=False), + sa.Column('heading_degrees', sa.Float(), nullable=True), + sa.Column('speed_mph', sa.Float(), nullable=True), + sa.Column('is_ecu_speed', sa.Boolean(), nullable=False), + sa.Column('formatted_location', sa.String(), nullable=True), + sa.Column('address_id', sa.String(), nullable=True), + sa.Column('address_name', sa.String(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['vehicle_id'], ['vehicles.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_vehicle_locations_vehicle_id'), 'vehicle_locations', ['vehicle_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_vehicle_locations_vehicle_id'), table_name='vehicle_locations') + op.drop_table('vehicle_locations') + op.drop_table('geofence_events') + op.drop_index(op.f('ix_driver_vehicle_assignments_vehicle_id'), table_name='driver_vehicle_assignments') + op.drop_index(op.f('ix_driver_vehicle_assignments_driver_id'), table_name='driver_vehicle_assignments') + op.drop_table('driver_vehicle_assignments') + op.drop_table('vehicles') + op.drop_table('drivers') + # ### end Alembic commands ### diff --git a/alembic/versions/648b513fafc7_add_composite_indices.py b/alembic/versions/648b513fafc7_add_composite_indices.py new file mode 100644 index 000000000..05bb29d24 --- /dev/null +++ b/alembic/versions/648b513fafc7_add_composite_indices.py @@ -0,0 +1,36 @@ +"""add composite indices + +Revision ID: 648b513fafc7 +Revises: 4f42c8d834fa +Create Date: 2025-12-25 12:55:12.028445 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '648b513fafc7' +down_revision: Union[str, None] = '4f42c8d834fa' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_index('ix_geofence_events_vehicle_time', 'geofence_events', ['vehicle_id', 'event_time'], unique=False) + op.drop_index(op.f('ix_vehicle_locations_vehicle_id'), table_name='vehicle_locations') + op.create_index('ix_vehicle_locations_vehicle_timestamp', 'vehicle_locations', ['vehicle_id', 'timestamp'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index('ix_vehicle_locations_vehicle_timestamp', table_name='vehicle_locations') + op.create_index(op.f('ix_vehicle_locations_vehicle_id'), 'vehicle_locations', ['vehicle_id'], unique=False) + op.drop_index('ix_geofence_events_vehicle_time', table_name='geofence_events') + # ### end Alembic commands ### diff --git a/alembic/versions/ac296168d213_enforce_uniqueness_constraint_of_.py b/alembic/versions/ac296168d213_enforce_uniqueness_constraint_of_.py new file mode 100644 index 000000000..8f881353f --- /dev/null +++ b/alembic/versions/ac296168d213_enforce_uniqueness_constraint_of_.py @@ -0,0 +1,32 @@ +"""enforce uniqueness constraint of vehicle and timestamp + +Revision ID: ac296168d213 +Revises: 648b513fafc7 +Create Date: 2025-12-25 16:06:42.170853 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'ac296168d213' +down_revision: Union[str, None] = '648b513fafc7' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_unique_constraint('uq_vehicle_locations_vehicle_timestamp', 'vehicle_locations', ['vehicle_id', 'timestamp']) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint('uq_vehicle_locations_vehicle_timestamp', 'vehicle_locations', type_='unique') + # ### end Alembic commands ### diff --git a/client/src/App.tsx b/client/src/App.tsx index 93b1e708b..33f72e75c 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,17 +4,17 @@ import { Route, } from 'react-router'; import './App.css'; -import LiveLocation from './pages/LiveLocation'; -import Schedule from './components/Schedule'; -import About from './pages/About'; -import Data from './pages/Data'; -import MapKitMap from './components/MapKitMap'; +import LiveLocation from './locations/LiveLocation'; +import Schedule from './schedule/Schedule'; +import About from './about/About'; +import Data from './dashboard/Dashboard'; +import MapKitMap from './locations/components/MapKitMap'; import rawRouteData from './data/routes.json'; import { useState, useEffect } from "react"; -import type { ShuttleRouteData } from './ts/types/route'; +import type { ShuttleRouteData } from './types/route'; import Navigation from './components/Navigation'; import ErrorBoundary from './components/ErrorBoundary'; -import config from "./ts/config"; +import config from "./utils/config"; function App() { const [selectedRoute, setSelectedRoute] = useState(null); diff --git a/client/src/pages/About.tsx b/client/src/about/About.tsx similarity index 97% rename from client/src/pages/About.tsx rename to client/src/about/About.tsx index 48f38b3cd..6f9e6a0c3 100644 --- a/client/src/pages/About.tsx +++ b/client/src/about/About.tsx @@ -1,4 +1,4 @@ -import '../styles/About.css'; +import './styles/About.css'; import { useState, useEffect, @@ -30,7 +30,7 @@ export default function About() {

Track RPI shuttles in real time and view schedules seamlessly with Shubble

Shubble is an open source project under the Rensselaer Center for Open Source (RCOS).
- Have an idea to improve it? Contributions are welcome!
+ Have an idea to improve it? Contributions are welcome!
Visit our Github Repository to learn more.
Interested in Shubble's data? Take a look at our diff --git a/client/src/styles/About.css b/client/src/about/styles/About.css similarity index 100% rename from client/src/styles/About.css rename to client/src/about/styles/About.css diff --git a/client/src/components/AnnouncementBanner.tsx b/client/src/components/AnnouncementBanner.tsx index 9bbb11861..94f687b92 100644 --- a/client/src/components/AnnouncementBanner.tsx +++ b/client/src/components/AnnouncementBanner.tsx @@ -1,6 +1,6 @@ -import '../styles/AnnouncementBanner.css'; +import './styles/AnnouncementBanner.css'; import announcementsData from '../data/announcements.json'; -import type { Announcement, AnnouncementsData } from '../ts/types/announcement'; +import type { Announcement, AnnouncementsData } from '../types/announcement'; import type { ReactNode } from 'react'; type BannerType = 'info' | 'warning' | 'error'; diff --git a/client/src/components/Feedback.tsx b/client/src/components/Feedback.tsx index 5a9568040..81014311c 100644 --- a/client/src/components/Feedback.tsx +++ b/client/src/components/Feedback.tsx @@ -1,4 +1,4 @@ -import '../styles/Feedback.css'; +import './styles/Feedback.css'; export default function Feedback() { return ( diff --git a/client/src/components/Navigation.tsx b/client/src/components/Navigation.tsx index b52d8b369..f2ae86884 100644 --- a/client/src/components/Navigation.tsx +++ b/client/src/components/Navigation.tsx @@ -1,7 +1,7 @@ import { Link, Outlet } from "react-router"; import Feedback from "./Feedback"; import AnnouncementBanner, { Banner } from "./AnnouncementBanner"; -import config from "../ts/config"; +import config from "../utils/config"; export default function Navigation({ GIT_REV }: { GIT_REV: string }) { // Build staging warning message with markdown link diff --git a/client/src/styles/AnnouncementBanner.css b/client/src/components/styles/AnnouncementBanner.css similarity index 100% rename from client/src/styles/AnnouncementBanner.css rename to client/src/components/styles/AnnouncementBanner.css diff --git a/client/src/styles/Feedback.css b/client/src/components/styles/Feedback.css similarity index 100% rename from client/src/styles/Feedback.css rename to client/src/components/styles/Feedback.css diff --git a/client/src/pages/Data.tsx b/client/src/dashboard/Dashboard.tsx similarity index 90% rename from client/src/pages/Data.tsx rename to client/src/dashboard/Dashboard.tsx index 9a0b1cad2..4f55cd6e6 100644 --- a/client/src/pages/Data.tsx +++ b/client/src/dashboard/Dashboard.tsx @@ -2,10 +2,11 @@ import { useState, useEffect, } from 'react'; -import "../styles/Data.css" -import DataBoard from '../components/DataBoard'; -import ShuttleRow from '../components/ShuttleRow'; -import type { VehicleInformationMap } from '../ts/types/vehicleLocation'; +import "./styles/Dashboard.css" +import DataBoard from './components/DataBoard'; +import ShuttleRow from './components/ShuttleRow'; +import type { VehicleInformationMap } from '../types/vehicleLocation'; +import config from '../utils/config'; export default function Data() { @@ -14,7 +15,7 @@ export default function Data() { const fetchShuttleData = async () => { try { - const response = await fetch('/api/today'); + const response = await fetch(`${config.apiBaseUrl}/api/today`); if (!response.ok) { throw new Error('Network response was not ok'); } diff --git a/client/src/components/DataBoard.tsx b/client/src/dashboard/components/DataBoard.tsx similarity index 100% rename from client/src/components/DataBoard.tsx rename to client/src/dashboard/components/DataBoard.tsx diff --git a/client/src/components/ShuttleRow.tsx b/client/src/dashboard/components/ShuttleRow.tsx similarity index 100% rename from client/src/components/ShuttleRow.tsx rename to client/src/dashboard/components/ShuttleRow.tsx diff --git a/client/src/components/StatusTag.tsx b/client/src/dashboard/components/StatusTag.tsx similarity index 100% rename from client/src/components/StatusTag.tsx rename to client/src/dashboard/components/StatusTag.tsx diff --git a/client/src/components/TimeTag.tsx b/client/src/dashboard/components/TimeTag.tsx similarity index 100% rename from client/src/components/TimeTag.tsx rename to client/src/dashboard/components/TimeTag.tsx diff --git a/client/src/styles/Data.css b/client/src/dashboard/styles/Dashboard.css similarity index 100% rename from client/src/styles/Data.css rename to client/src/dashboard/styles/Dashboard.css diff --git a/client/src/styles/DataBoard.css b/client/src/dashboard/styles/DataBoard.css similarity index 100% rename from client/src/styles/DataBoard.css rename to client/src/dashboard/styles/DataBoard.css diff --git a/client/src/styles/ShuttleRow.css b/client/src/dashboard/styles/ShuttleRow.css similarity index 100% rename from client/src/styles/ShuttleRow.css rename to client/src/dashboard/styles/ShuttleRow.css diff --git a/client/src/styles/StatusTag.css b/client/src/dashboard/styles/StatusTag.css similarity index 100% rename from client/src/styles/StatusTag.css rename to client/src/dashboard/styles/StatusTag.css diff --git a/client/src/styles/TimeTag.css b/client/src/dashboard/styles/TimeTag.css similarity index 100% rename from client/src/styles/TimeTag.css rename to client/src/dashboard/styles/TimeTag.css diff --git a/client/src/pages/LiveLocation.tsx b/client/src/locations/LiveLocation.tsx similarity index 86% rename from client/src/pages/LiveLocation.tsx rename to client/src/locations/LiveLocation.tsx index 1a08550ab..3ca47aafe 100644 --- a/client/src/pages/LiveLocation.tsx +++ b/client/src/locations/LiveLocation.tsx @@ -3,11 +3,11 @@ import { useEffect, } from 'react'; -import MapKitMap from '../components/MapKitMap'; -import Schedule from '../components/Schedule'; -import "../styles/LiveLocation.css"; +import MapKitMap from './components/MapKitMap'; +import Schedule from '../schedule/Schedule'; +import "./styles/LiveLocation.css"; import routeData from '../data/routes.json'; -import type { ShuttleRouteData } from '../ts/types/route'; +import type { ShuttleRouteData } from '../types/route'; import aggregatedSchedule from '../data/aggregated_schedule.json'; export default function LiveLocation() { diff --git a/client/src/locations/MapKitMap.tsx b/client/src/locations/MapKitMap.tsx new file mode 100644 index 000000000..090fadd01 --- /dev/null +++ b/client/src/locations/MapKitMap.tsx @@ -0,0 +1,736 @@ +import { useEffect, useRef, useState, useMemo } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import './Locations.css'; +import ShuttleIcon from "./ShuttleIcon"; +import config from "../components/config"; + +import type { ShuttleRouteData, ShuttleStopData } from "../types/route"; +import type { VehicleInformationMap } from "../types/vehicleLocation"; + +import { + type Coordinate, + findNearestPointOnPolyline, + moveAlongPolyline, + calculateDistanceAlongPolyline, + calculateBearing, + getAngleDifference +} from "../components/mapUtils"; + +async function generateRoutePolylines(updatedRouteData: ShuttleRouteData) { + // Use MapKit Directions API to generate polylines for each route segment + const directions = new mapkit.Directions(); + + for (const [routeName, routeInfo] of Object.entries(updatedRouteData)) { + const polyStops = routeInfo.POLYLINE_STOPS || []; + const realStops = routeInfo.STOPS || []; + + // Initialize ROUTES with empty arrays for each real stop segment + routeInfo.ROUTES = Array(realStops.length - 1).fill(null).map(() => []); + + // Index of the current real stop segment we are populating + // polyStops may include intermediate points between real stops + let currentRealIndex = 0; + + for (let i = 0; i < polyStops.length - 1; i++) { + // Get origin and destination stops + const originStop = polyStops[i]; + const destStop = polyStops[i + 1]; + const originCoords = (routeInfo[originStop] as ShuttleStopData)?.COORDINATES; + const destCoords = (routeInfo[destStop] as ShuttleStopData)?.COORDINATES; + if (!originCoords || !destCoords) continue; + + // Fetch segment polyline + const segment = await new Promise((resolve) => { + directions.route( + { + origin: new mapkit.Coordinate(originCoords[0], originCoords[1]), + destination: new mapkit.Coordinate(destCoords[0], destCoords[1]), + }, + (error, data) => { + if (error) { + console.error(`Directions error for ${routeName} segment ${originStop}→${destStop}:`, error); + resolve([]); + return; + } + try { + const coords = data.routes[0].polyline.points.map(pt => [pt.latitude, pt.longitude]); + resolve(coords as [number, number][]); + } catch (e) { + console.error(`Unexpected response parsing for ${routeName} segment ${originStop}→${destStop}:`, e); + resolve([]); + } + } + ); + }) as [number, number][]; + + // Add to the current real stop segment + if (segment.length > 0) { + if (routeInfo.ROUTES[currentRealIndex].length === 0) { + // first segment for this route piece + routeInfo.ROUTES[currentRealIndex].push(...segment); + } else { + // append, avoiding duplicate join point + routeInfo.ROUTES[currentRealIndex].push(...segment.slice(1)); + } + } + + // If the destStop is the next real stop, move to the next ROUTES index + if (destStop === realStops[currentRealIndex + 1]) { + currentRealIndex++; + } + } + } + + // Trigger download + function downloadJSON(data: ShuttleRouteData, filename = 'routeData.json') { + const jsonStr = JSON.stringify(data, null, 2); + const blob = new Blob([jsonStr], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + setTimeout(() => { + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, 100); + } + + downloadJSON(updatedRouteData); + return updatedRouteData; +} + +type MapKitMapProps = { + routeData: ShuttleRouteData | null; + displayVehicles?: boolean; + generateRoutes?: boolean; + selectedRoute?: string | null; + setSelectedRoute?: (route: string | null) => void; + isFullscreen?: boolean; +}; + +// @ts-expect-error selectedRoutes is never used +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function MapKitMap({ routeData, displayVehicles = true, generateRoutes = false, selectedRoute, setSelectedRoute, isFullscreen = false }: MapKitMapProps) { + const mapRef = useRef(null); + const [mapLoaded, setMapLoaded] = useState(false); + const token = import.meta.env.VITE_MAPKIT_KEY; + const [map, setMap] = useState<(mapkit.Map | null)>(null); + const [vehicles, setVehicles] = useState(null); + + const vehicleOverlays = useRef>({}); + const vehicleAnimationStates = useRef>({}); + const animationFrameId = useRef(null); + + + const circleWidth = 15; + const selectedMarkerRef = useRef(null); + const overlays: mapkit.Overlay[] = []; + + // source: https://developer.apple.com/documentation/mapkitjs/loading-the-latest-version-of-mapkit-js + const setupMapKitJs = async () => { + if (!window.mapkit || window.mapkit.loadedLibraries.length === 0) { + await new Promise(resolve => { window.initMapKit = () => resolve(null); }); + delete window.initMapKit; + } + }; + + useEffect(() => { + // initialize mapkit + const mapkitScript = async () => { + // load the MapKit JS library + await setupMapKitJs(); + mapkit.init({ + authorizationCallback: (done) => { + done(token); + }, + }); + setMapLoaded(true); + }; + mapkitScript(); + }, []); + + // Fetch location data on component mount and set up polling + useEffect(() => { + if (!displayVehicles) return; + + const pollLocation = async () => { + try { + const response = await fetch(`${config.apiBaseUrl}/api/locations`); + if (!response.ok) { + throw new Error('Network response was not ok'); + } + const data = await response.json(); + setVehicles(data); + } catch (error) { + console.error('Error fetching location:', error); + } + } + + pollLocation(); + + // refresh location every 5 seconds + const refreshLocation = setInterval(pollLocation, 5000); + + return () => { + clearInterval(refreshLocation); + } + + }, []); + + // create the map + useEffect(() => { + if (mapLoaded) { + + // center on RPI + const center = new mapkit.Coordinate(42.730216, -73.675690); + const span = new mapkit.CoordinateSpan(0.02, 0.005); + const region = new mapkit.CoordinateRegion(center, span); + + const mapOptions = { + center: center, + region: region, + isScrollEnabled: true, + isZoomEnabled: true, + showsZoomControl: true, + isRotationEnabled: false, + showsPointsOfInterest: false, + showsUserLocation: true, + }; + + // create the map + const thisMap = new mapkit.Map(mapRef.current!, mapOptions); + // set zoom and boundary limits + thisMap.setCameraZoomRangeAnimated( + new mapkit.CameraZoomRange(200, 3000), + false, + ); + thisMap.setCameraBoundaryAnimated( + new mapkit.CoordinateRegion( + center, + new mapkit.CoordinateSpan(0.02, 0.025) + ), + false, + ); + thisMap.setCameraDistanceAnimated(2500); + // Helper function to create and add stop marker + const createStopMarker = (overlay: mapkit.CircleOverlay) => { + if (selectedMarkerRef.current) { + thisMap.removeAnnotation(selectedMarkerRef.current); + selectedMarkerRef.current = null; + } + const marker = new mapkit.MarkerAnnotation(overlay.coordinate, { + title: overlay.stopName, + glyphImage: { 1: "map-marker.png" }, + }); + thisMap.addAnnotation(marker); + selectedMarkerRef.current = marker; + return marker; + }; + + thisMap.addEventListener("select", (e) => { + if (!e.overlay) return; + if (!(e.overlay instanceof mapkit.CircleOverlay)) return; + + // Only change schedule selection on desktop-sized screens + const isDesktop = window.matchMedia('(min-width: 800px)').matches; + + if (e.overlay.stopKey) { + // Create marker for both mobile and desktop + createStopMarker(e.overlay); + + if (isDesktop) { + // Desktop: handle schedule change + const routeKey = e.overlay.routeKey; + if (setSelectedRoute && routeKey) setSelectedRoute(routeKey); + } + } + }); + thisMap.addEventListener("deselect", () => { + // remove any selected stop/marker annotation on when deselected + if (selectedMarkerRef.current) { + thisMap.removeAnnotation(selectedMarkerRef.current); + selectedMarkerRef.current = null; + } + }); + + thisMap.addEventListener("region-change-start", () => { + (thisMap.element as HTMLElement).style.cursor = "grab"; + }); + + thisMap.addEventListener("region-change-end", () => { + (thisMap.element as HTMLElement).style.cursor = "default"; + }); + + // Working hover detection + let currentHover: mapkit.CircleOverlay | null = null; + thisMap.element.addEventListener('mousemove', (e) => { + const rect = thisMap.element.getBoundingClientRect(); + const x = (e as MouseEvent).clientX - rect.left; + const y = (e as MouseEvent).clientY - rect.top; + + let foundOverlay: mapkit.CircleOverlay | null = null; + + // Check overlays for mouse position + for (const overlay of thisMap.overlays) { + if (!(overlay instanceof mapkit.CircleOverlay)) continue; + if (overlay.stopKey) { + // Calculate overlay screen position + const mapRect = thisMap.element.getBoundingClientRect(); + const centerLat = overlay.coordinate.latitude; + const centerLng = overlay.coordinate.longitude; + + // Check if mouse is within overlay radius + const region = thisMap.region; + if (region) { + const centerX = mapRect.width * (centerLng - region.center.longitude + region.span.longitudeDelta / 2) / region.span.longitudeDelta; + const centerY = mapRect.height * (region.center.latitude - centerLat + region.span.latitudeDelta / 2) / region.span.latitudeDelta; + + const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + if (distance < circleWidth) { // Within hover radius + foundOverlay = overlay; + } + } + } + } + + if (foundOverlay !== currentHover) { + // Clear previous hover style + if (currentHover) { + currentHover.style = new mapkit.Style({ + strokeColor: '#000000', + fillColor: '#FFFFFF', + fillOpacity: 0.1, + lineWidth: 2, + }); + } + + // Apply hover style + if (foundOverlay) { + foundOverlay.style = new mapkit.Style({ + strokeColor: '#6699ff', + fillColor: '#a1c3ff', + fillOpacity: 0.3, + lineWidth: 2.5, + }); + (thisMap.element as HTMLElement).style.cursor = "pointer"; + } else { + (thisMap.element as HTMLElement).style.cursor = "default"; + } + + currentHover = foundOverlay; + } + }); + + // Store reference to cleanup function + thisMap._hoverCleanup = () => { + // thisMap.element.removeEventListener('mousemove', _); + }; + + setMap(thisMap); + } + + // Cleanup on component unmount + return () => { + if (map && map._hoverCleanup) { + map._hoverCleanup(); + } + }; + }, [mapLoaded]); + + // add fixed details to the map + // includes routes and stops + useEffect(() => { + if (!map || !routeData) return; + + + // display stop overlays + for (const [route, thisRouteData] of Object.entries(routeData)) { + for (const stopKey of thisRouteData.STOPS) { + const stopData = thisRouteData[stopKey] as ShuttleStopData; + const stopCoordinate = new mapkit.Coordinate(...(stopData.COORDINATES)); + // add stop overlay (circle) + const stopOverlay = new mapkit.CircleOverlay( + stopCoordinate, + circleWidth, + { + style: new mapkit.Style( + { + strokeColor: '#000000', + fillColor: '#FFFFFF', // White fill by default + fillOpacity: 0.1, + lineWidth: 2, + } + ) + } + ); + // attach exact identifiers so the select handler can update selection precisely + stopOverlay.routeKey = route; + stopOverlay.stopKey = stopKey; + stopOverlay.stopName = stopData.NAME; + // cast circle overlay to generic overlay for adding to map + overlays.push(stopOverlay as mapkit.Overlay); + } + } + + function displayRouteOverlays(routeData: ShuttleRouteData) { + // display route overlays + for (const [_route, thisRouteData] of Object.entries(routeData)) { + // for route (WEST, NORTH) + const routePolylines = thisRouteData.ROUTES?.map( + // for segment (STOP1 -> STOP2, STOP2 -> STOP3, ...) + (route) => { + const coords = route.map(([lat, lon]) => new mapkit.Coordinate(lat, lon)); + if (coords.length === 0) return null; + const polyline = new mapkit.PolylineOverlay(coords, { + // for coordinate ([lat, lon], ...) + style: new mapkit.Style({ + strokeColor: thisRouteData.COLOR, + lineWidth: 2 + }) + }); + return polyline; + } + ).filter(p => p !== null); + overlays.push(...routePolylines as mapkit.Overlay[]); + } + } + + if (generateRoutes) { + // generate polylines for routes + const routeDataCopy = JSON.parse(JSON.stringify(routeData)); // deep copy to avoid mutating original + generateRoutePolylines(routeDataCopy).then((updatedRouteData) => { + displayRouteOverlays(updatedRouteData); + map.addOverlays(overlays); + }); + } else { + // use pre-generated polylines + displayRouteOverlays(routeData); + map.addOverlays(overlays); + } + + }, [map, routeData]); + + // Memoize flattened routes to avoid recalculating on every render + const flattenedRoutes = useMemo(() => { + if (!routeData) return {}; + const flattened: Record = {}; + + for (const [routeKey, data] of Object.entries(routeData)) { + if (data.ROUTES) { + // Flatten all route segments into one continuous polyline + const points: Coordinate[] = []; + data.ROUTES.forEach(segment => { + segment.forEach(pt => { + points.push({ latitude: pt[0], longitude: pt[1] }); + }); + }); + flattened[routeKey] = points; + } + } + return flattened; + }, [routeData]); + + // display vehicles on map + useEffect(() => { + if (!map || !vehicles) return; + + Object.keys(vehicles).forEach((key) => { + const vehicle = vehicles[key]; + const coordinate = new window.mapkit.Coordinate(vehicle.latitude, vehicle.longitude); + + const existingAnnotation = vehicleOverlays.current[key]; + + // Build SVG dynamically using ShuttleIcon component + const routeColor = (() => { + if (!routeData || !vehicle.route_name || vehicle.route_name === "UNCLEAR") { + return "#444444"; + } + const routeKey = vehicle.route_name as keyof typeof routeData; + const info = routeData[routeKey] as { COLOR?: string }; + return info.COLOR ?? "#444444"; + + })(); + + // Render ShuttleIcon JSX to a static SVG string + const svgString = renderToStaticMarkup(); + const svgShuttle = `data:image/svg+xml;base64,${btoa(svgString)}`; + + // --- Update or create annotation --- + if (existingAnnotation) { + // existing vehicle — update position and subtitle + // Only update coordinate directly if we don't have an animation state (to avoid flicker) + if (!vehicleAnimationStates.current[key]) { + existingAnnotation.coordinate = coordinate; + } + existingAnnotation.subtitle = `${vehicle.speed_mph.toFixed(1)} mph`; + + // Handle route status updates + // If shuttle does not have a route null + if (vehicle.route_name === null) { + // shuttle off-route (exiting) + if (existingAnnotation.lockedRoute) { + existingAnnotation.lockedRoute = null; + existingAnnotation.url = { 1: svgShuttle }; + } + } else if (vehicle.route_name !== "UNCLEAR" && vehicle.route_name !== existingAnnotation.lockedRoute) { + existingAnnotation.lockedRoute = vehicle.route_name; + existingAnnotation.url = { 1: svgShuttle }; + } + } else { + const annotationOptions = { + title: vehicle.name, + subtitle: `${vehicle.speed_mph.toFixed(1)} mph`, + url: { 1: svgShuttle }, + size: { width: 25, height: 25 }, + anchorOffset: new DOMPoint(0, -13), + }; + + // create shuttle object + const annotation = new window.mapkit.ImageAnnotation(coordinate, annotationOptions) as mapkit.ShuttleAnnotation; + + + // lock route if known + if (vehicle.route_name !== "UNCLEAR" && vehicle.route_name !== null) { + annotation.lockedRoute = vehicle.route_name; + } + + // add shuttle to map + map.addAnnotation(annotation); + vehicleOverlays.current[key] = annotation; + } + }); + + // --- Update Animation State for new/updated vehicles --- + const now = Date.now(); + Object.keys(vehicles).forEach((key) => { + const vehicle = vehicles[key]; + // If we don't have a route for this vehicle, we can't animate along a path nicely. + // We'll just rely on the API updates or maybe simple linear extrapolation later? + // For now, let's only set up animation if we have a valid route. + if (!vehicle.route_name || !flattenedRoutes[vehicle.route_name]) return; + + const routePolyline = flattenedRoutes[vehicle.route_name]; + const vehicleCoord = { latitude: vehicle.latitude, longitude: vehicle.longitude }; + + const serverTime = new Date(vehicle.timestamp).getTime(); + + // Check if we already have state + let animState = vehicleAnimationStates.current[key]; + + // If the server data hasn't changed (cached response), ignore this update + // and let the client-side prediction continue running. + if (animState && animState.lastServerTime === serverTime) { + return; + } + + const snapToPolyline = () => { + const { index, point } = findNearestPointOnPolyline(vehicleCoord, routePolyline); + vehicleAnimationStates.current[key] = { + lastUpdateTime: now, + polylineIndex: index, + currentPoint: point, + targetDistance: 0, + distanceTraveled: 0, + lastServerTime: serverTime + }; + }; + + if (!animState) { + snapToPolyline(); + } else { + // ======================================================================= + // PREDICTION SMOOTHING ALGORITHM + // ======================================================================= + // Problem: Server updates arrive every ~5 seconds, causing the shuttle + // to "jump" to its new position (rubberbanding). + // + // Solution: Instead of jumping, we calculate the speed needed for the + // shuttle to smoothly travel from its current visual position to where + // it *should* be when the next update arrives. + // + // Formula: speed = distance / time + // Where: + // - distance = gap between current visual position and predicted target + // - time = 5 seconds (the update interval) + // ======================================================================= + + const PREDICTION_WINDOW_SECONDS = 5; + + // Step 1: Find where the server says the shuttle is right now + const { index: serverIndex, point: serverPoint } = findNearestPointOnPolyline(vehicleCoord, routePolyline); + + // Step 2: Calculate where the shuttle will be in 5 seconds + // Convert speed from mph to meters/second (1 mph = 0.44704 m/s) + const speedMetersPerSecond = vehicle.speed_mph * 0.44704; + const projectedDistanceMeters = speedMetersPerSecond * PREDICTION_WINDOW_SECONDS; + + // Move along the route polyline by that distance to find the target point + const { index: targetIndex, point: targetPoint } = moveAlongPolyline( + routePolyline, + serverIndex, + serverPoint, + projectedDistanceMeters + ); + + // Step 3: Verify the shuttle is moving in the correct direction + // Compare the vehicle's GPS heading to the route segment bearing. + // If they differ by more than 90°, the shuttle may be going the wrong way. + let isMovingCorrectDirection = true; + if (routePolyline.length > serverIndex + 1 && vehicle.speed_mph > 1) { + const segmentStart = routePolyline[serverIndex]; + const segmentEnd = routePolyline[serverIndex + 1]; + const segmentBearing = calculateBearing(segmentStart, segmentEnd); + const headingDifference = getAngleDifference(segmentBearing, vehicle.heading_degrees); + + if (headingDifference > 90) { + isMovingCorrectDirection = false; + } + } + + // Step 4: Calculate distance from current visual position to target + const distanceToTarget = calculateDistanceAlongPolyline( + routePolyline, + animState.polylineIndex, + animState.currentPoint, + targetIndex, + targetPoint + ); + + // Step 5: Calculate the total distance to travel with easing + let targetDistanceMeters = distanceToTarget; + + // If moving wrong direction, stop the animation + if (!isMovingCorrectDirection) { + targetDistanceMeters = 0; + } + + // Step 6: Update animation state. + // If the gap is extremely large (>250m in either direction), snap to server position. + // For smaller backward gaps, animate smoothly backward to correct the overprediction. + const MAX_REASONABLE_GAP_METERS = 250; + if (Math.abs(distanceToTarget) > MAX_REASONABLE_GAP_METERS) { + snapToPolyline(); + } else { + // Allow negative targetDistance for smooth backward animation + // Reset the animation progress - we're starting a new prediction window + vehicleAnimationStates.current[key] = { + lastUpdateTime: now, + polylineIndex: animState.polylineIndex, + currentPoint: animState.currentPoint, + targetDistance: targetDistanceMeters, + distanceTraveled: 0, + lastServerTime: serverTime + }; + } + } + }); + + // --- Remove stale vehicles --- + const currentVehicleKeys = new Set(Object.keys(vehicles)); + Object.keys(vehicleOverlays.current).forEach((key) => { + if (!currentVehicleKeys.has(key)) { + map.removeAnnotation(vehicleOverlays.current[key]); + delete vehicleOverlays.current[key]; + } + }); + }, [map, vehicles, routeData]); + + + // --- Animation Loop --- + useEffect(() => { + // We use setTimeout/setInterval or requestAnimationFrame. The user "Considered" setTimeout. + // We will use requestAnimationFrame for smoothness, but structure it to calculate delta + // similar to how one might with setTimeout. + + let lastFrameTime = Date.now(); + + const animate = () => { + const now = Date.now(); + const dt = now - lastFrameTime; // ms + lastFrameTime = now; + + // Avoid huge jumps if tab was backgrounded + if (dt > 1000) { + animationFrameId.current = requestAnimationFrame(animate); + return; + } + + Object.keys(vehicleAnimationStates.current).forEach(key => { + const animState = vehicleAnimationStates.current[key]; + const vehicle = vehicles?.[key]; + const annotation = vehicleOverlays.current[key]; + + if (!vehicle || !annotation || !animState) return; + if (!vehicle.route_name || !flattenedRoutes[vehicle.route_name]) return; + + const routePolyline = flattenedRoutes[vehicle.route_name]; + + // ======================================================================= + // EASED ANIMATION + // ======================================================================= + // Instead of constant speed, we use an ease-in-out curve. + // This makes the shuttle accelerate at the start and decelerate at the end + // of each prediction window, creating smoother, more natural motion. + // ======================================================================= + + const PREDICTION_WINDOW_MS = 5000; + const timeElapsed = now - animState.lastUpdateTime; + + // Calculate progress through the prediction window (0.0 to 1.0) + const progress = Math.min(timeElapsed / PREDICTION_WINDOW_MS, 1.0); + + // Calculate how far along the target distance we should be (linear interpolation) + const targetPosition = animState.targetDistance * progress; + + // Calculate how much to move this frame (can be negative for backward movement) + const distanceToMove = targetPosition - animState.distanceTraveled; + + // Skip if no movement needed + if (distanceToMove === 0) return; + + // Move along polyline + const { index, point } = moveAlongPolyline( + routePolyline, + animState.polylineIndex, + animState.currentPoint, + distanceToMove + ); + + // Update state + animState.polylineIndex = index; + animState.currentPoint = point; + animState.distanceTraveled = targetPosition; + + // Update MapKit annotation + annotation.coordinate = new mapkit.Coordinate(point.latitude, point.longitude); + }); + + animationFrameId.current = requestAnimationFrame(animate); + }; + + animationFrameId.current = requestAnimationFrame(animate); + + return () => { + if (animationFrameId.current) cancelAnimationFrame(animationFrameId.current); + }; + }, [vehicles]); // Restart loop if vehicles change? Not strictly necessary if refs are used, but ensures we have latest `vehicles` closure if needed. Actually with refs we don't need to dependency on vehicles often if we read from ref, but here we read `vehicles` prop. + + + + return ( +

+
+ ); +}; diff --git a/client/src/components/DataAgeIndicator.tsx b/client/src/locations/components/DataAgeIndicator.tsx similarity index 100% rename from client/src/components/DataAgeIndicator.tsx rename to client/src/locations/components/DataAgeIndicator.tsx diff --git a/client/src/components/MapKitMap.tsx b/client/src/locations/components/MapKitMap.tsx similarity index 98% rename from client/src/components/MapKitMap.tsx rename to client/src/locations/components/MapKitMap.tsx index 866e09433..59d7877b1 100644 --- a/client/src/components/MapKitMap.tsx +++ b/client/src/locations/components/MapKitMap.tsx @@ -2,9 +2,10 @@ import { useEffect, useRef, useState, useMemo } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import '../styles/MapKitMap.css'; import ShuttleIcon from "./ShuttleIcon"; +import config from "../../utils/config"; -import type { ShuttleRouteData, ShuttleStopData } from "../ts/types/route"; -import type { VehicleInformationMap } from "../ts/types/vehicleLocation"; +import type { ShuttleRouteData, ShuttleStopData } from "../../types/route"; +import type { VehicleInformationMap } from "../../types/vehicleLocation"; import DataAgeIndicator from "./DataAgeIndicator"; import { @@ -14,7 +15,7 @@ import { calculateDistanceAlongPolyline, calculateBearing, getAngleDifference -} from "../ts/mapUtils"; +} from "../../utils/mapUtils"; // Helper function to remove consecutive duplicate points from a route function removeDuplicateConsecutivePoints(route: [number, number][]): [number, number][] { @@ -186,7 +187,7 @@ export default function MapKitMap({ routeData, displayVehicles = true, generateR const pollLocation = async () => { try { - const response = await fetch('/api/locations'); + const response = await fetch(`${config.apiBaseUrl}/api/locations`); if (!response.ok) { throw new Error('Network response was not ok'); } @@ -514,7 +515,7 @@ export default function MapKitMap({ routeData, displayVehicles = true, generateR existingAnnotation.subtitle = `${vehicle.speed_mph.toFixed(1)} mph`; // Handle route status updates - // If shuttle does not have a route null + // If shuttle does not have a route null if (vehicle.route_name === null) { // shuttle off-route (exiting) if (existingAnnotation.lockedRoute) { @@ -553,7 +554,7 @@ export default function MapKitMap({ routeData, displayVehicles = true, generateR const now = Date.now(); Object.keys(vehicles).forEach((key) => { const vehicle = vehicles[key]; - // If we don't have a route for this vehicle, we can't animate along a path nicely. + // If we don't have a route for this vehicle, we can't animate along a path nicely. // We'll just rely on the API updates or maybe simple linear extrapolation later? // For now, let's only set up animation if we have a valid route. if (!vehicle.route_name || !flattenedRoutes[vehicle.route_name]) return; diff --git a/client/src/components/ShuttleIcon.tsx b/client/src/locations/components/ShuttleIcon.tsx similarity index 100% rename from client/src/components/ShuttleIcon.tsx rename to client/src/locations/components/ShuttleIcon.tsx diff --git a/client/src/styles/DataAgeIndicator.css b/client/src/locations/styles/DataAgeIndicator.css similarity index 100% rename from client/src/styles/DataAgeIndicator.css rename to client/src/locations/styles/DataAgeIndicator.css diff --git a/client/src/styles/LiveLocation.css b/client/src/locations/styles/LiveLocation.css similarity index 100% rename from client/src/styles/LiveLocation.css rename to client/src/locations/styles/LiveLocation.css diff --git a/client/src/styles/MapKitMap.css b/client/src/locations/styles/MapKitMap.css similarity index 100% rename from client/src/styles/MapKitMap.css rename to client/src/locations/styles/MapKitMap.css diff --git a/client/src/components/Schedule.tsx b/client/src/schedule/Schedule.tsx similarity index 96% rename from client/src/components/Schedule.tsx rename to client/src/schedule/Schedule.tsx index 328c6b36f..84fa3e718 100644 --- a/client/src/components/Schedule.tsx +++ b/client/src/schedule/Schedule.tsx @@ -1,10 +1,10 @@ import { useState, useEffect } from 'react'; -import '../styles/Schedule.css'; +import './styles/Schedule.css'; import rawRouteData from '../data/routes.json'; import rawAggregatedSchedule from '../data/aggregated_schedule.json'; -import type { AggregatedDaySchedule, AggregatedScheduleType} from '../ts/types/schedule'; -import type { ShuttleRouteData, ShuttleStopData } from '../ts/types/route'; -import {buildAllStops, findClosestStop, type Stop, type ClosestStop, } from '../ts/types/ClosestStop'; +import type { AggregatedDaySchedule, AggregatedScheduleType} from '../types/schedule'; +import type { ShuttleRouteData, ShuttleStopData } from '../types/route'; +import {buildAllStops, findClosestStop, type Stop, type ClosestStop, } from '../types/ClosestStop'; @@ -37,7 +37,7 @@ export default function Schedule({ selectedRoute, setSelectedRoute }: SchedulePr const stopsToday = buildAllStops(routeData, aggregatedSchedule, selectedDay); setAllStops(stopsToday); }, [selectedDay]); - + // Define safe values to avoid repeated null checks const safeSelectedRoute = selectedRoute || routeNames[0]; @@ -88,7 +88,7 @@ export default function Schedule({ selectedRoute, setSelectedRoute }: SchedulePr return date; } -// Use user location and get closest stop to them +// Use user location and get closest stop to them useEffect(() => { if (!('geolocation' in navigator)) return; @@ -154,7 +154,7 @@ export default function Schedule({ selectedRoute, setSelectedRoute }: SchedulePr
- +