From 1685067f8a7d68d09767fdcd1065914f6d1c9dd3 Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Thu, 8 Jan 2026 10:43:27 -0500 Subject: [PATCH 1/6] Package upgrades --- CLAUDE.md | 52 +++++++++ next-env.d.ts | 2 +- package.json | 4 +- pnpm-lock.yaml | 302 ++++++++++++++++++++++++------------------------- 4 files changed, 206 insertions(+), 154 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a2e0cce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,52 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pnpm dev # Start development server at localhost:3000 +pnpm build # Build for production +pnpm test # Run tests in watch mode +pnpm test:run # Run tests once +pnpm lint # Run ESLint + Biome lint + Biome format checks +pnpm lint:fix # Auto-fix linting/formatting issues +``` + +## Architecture + +This is a Next.js 15 App Router application displaying an interactive Mapbox map of Chattanooga bike routes and resources. + +### Core Data Flow + +1. **Page Entry** (`src/app/page.tsx`): Dynamically imports Map component with SSR disabled (Mapbox requires browser) +2. **Map Component** (`src/components/Map.tsx`): Main orchestrator that initializes Mapbox, manages markers, and handles custom events +3. **Data Sources** (`src/data/`): + - `geo_data.ts`: Static data for bike routes, attractions, bike shops (BikeRoute, MapFeature, BikeResource interfaces) + - `gbfs.ts`: Live bike share station data from Chattanooga GBFS API + +### Event-Driven Communication + +The app uses custom DOM events for component communication: +- `route-select`: Highlights a bike route and zooms to its bounds +- `layer-toggle`: Shows/hides marker layers (attractions, bikeResources, bikeRentals) +- `center-location`: Pans map to a specific location +- `route-deselect`: Resets route opacity +- `sidebar-toggle`: Triggers map resize after sidebar animation + +### Marker System + +`MapMarkers.tsx` provides factory functions for different marker types and a `MarkerManager` class for bulk operations. Markers are pre-created at init but only added to map when their layer is toggled on. + +### Map Styling + +Routes are styled via Mapbox Studio (referenced by layer IDs like `riverwalk-loop-v3-public`). Route bounds are calculated from layer features at runtime to enable zoom-to-fit. + +## Code Style + +- Use `function` keyword for pure functions and components +- Prefer interfaces over type aliases; avoid enums (use maps) +- Use functional components; minimize `use client` +- File order: exported component → subcomponents → helpers → static content → types +- Use existing icon libraries (Font Awesome or lucide-react) - don't add new ones +- Directories use lowercase-dash naming diff --git a/next-env.d.ts b/next-env.d.ts index 830fb59..3cd7048 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -/// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 7f577e8..db5a6b4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "bikemap", "version": "0.1.0", "private": true, - "packageManager": "pnpm@10.15.1", + "packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a", "scripts": { "dev": "next dev", "build": "next build", @@ -35,7 +35,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { - "@biomejs/biome": "^2.3.10", + "@biomejs/biome": "^2.3.11", "@eslint/eslintrc": "^3.3.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee59603..9b24bca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,8 +67,8 @@ importers: version: 1.0.7(tailwindcss@3.4.19(yaml@2.8.2)) devDependencies: '@biomejs/biome': - specifier: ^2.3.10 - version: 2.3.10 + specifier: ^2.3.11 + version: 2.3.11 '@eslint/eslintrc': specifier: ^3.3.3 version: 3.3.3 @@ -98,7 +98,7 @@ importers: version: 15.5.9(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint-plugin-import: specifier: ^2.32.0 - version: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + version: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) husky: specifier: ^9.1.7 version: 9.1.7 @@ -157,55 +157,55 @@ packages: resolution: {integrity: sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==} engines: {node: '>=6.9.0'} - '@biomejs/biome@2.3.10': - resolution: {integrity: sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ==} + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} engines: {node: '>=14.21.3'} hasBin: true - '@biomejs/cli-darwin-arm64@2.3.10': - resolution: {integrity: sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w==} + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [darwin] - '@biomejs/cli-darwin-x64@2.3.10': - resolution: {integrity: sha512-Vae7+V6t/Avr8tVbFNjnFSTKZogZHFYl7MMH62P/J1kZtr0tyRQ9Fe0onjqjS2Ek9lmNLmZc/VR5uSekh+p1fg==} + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [darwin] - '@biomejs/cli-linux-arm64-musl@2.3.10': - resolution: {integrity: sha512-B9DszIHkuKtOH2IFeeVkQmSMVUjss9KtHaNXquYYWCjH8IstNgXgx5B0aSBQNr6mn4RcKKRQZXn9Zu1rM3O0/A==} + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-arm64@2.3.10': - resolution: {integrity: sha512-hhPw2V3/EpHKsileVOFynuWiKRgFEV48cLe0eA+G2wO4SzlwEhLEB9LhlSrVeu2mtSn205W283LkX7Fh48CaxA==} + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - '@biomejs/cli-linux-x64-musl@2.3.10': - resolution: {integrity: sha512-QTfHZQh62SDFdYc2nfmZFuTm5yYb4eO1zwfB+90YxUumRCR171tS1GoTX5OD0wrv4UsziMPmrePMtkTnNyYG3g==} + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-linux-x64@2.3.10': - resolution: {integrity: sha512-wwAkWD1MR95u+J4LkWP74/vGz+tRrIQvr8kfMMJY8KOQ8+HMVleREOcPYsQX82S7uueco60L58Wc6M1I9WA9Dw==} + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - '@biomejs/cli-win32-arm64@2.3.10': - resolution: {integrity: sha512-o7lYc9n+CfRbHvkjPhm8s9FgbKdYZu5HCcGVMItLjz93EhgJ8AM44W+QckDqLA9MKDNFrR8nPbO4b73VC5kGGQ==} + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [win32] - '@biomejs/cli-win32-x64@2.3.10': - resolution: {integrity: sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ==} + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [win32] @@ -234,19 +234,19 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.22': - resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + '@csstools/css-syntax-patches-for-csstree@1.0.23': + resolution: {integrity: sha512-YEmgyklR6l/oKUltidNVYdjSmLSW88vMsKx0pmiS3r71s8ZZRpd8A0Yf0U+6p/RzElmMnPBv27hNWjDQMSZRtQ==} engines: {node: '>=18'} '@csstools/css-tokenizer@3.0.4': resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} @@ -407,8 +407,8 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 @@ -1239,63 +1239,63 @@ packages: '@types/supercluster@7.1.3': resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} - '@typescript-eslint/eslint-plugin@8.51.0': - resolution: {integrity: sha512-XtssGWJvypyM2ytBnSnKtHYOGT+4ZwTnBVl36TA4nRO2f4PRNGz5/1OszHzcZCvcBMh+qb7I06uoCmLTRdR9og==} + '@typescript-eslint/eslint-plugin@8.52.0': + resolution: {integrity: sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.51.0 + '@typescript-eslint/parser': ^8.52.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.51.0': - resolution: {integrity: sha512-3xP4XzzDNQOIqBMWogftkwxhg5oMKApqY0BAflmLZiFYHqyhSOxv/cd/zPQLTcCXr4AkaKb25joocY0BD1WC6A==} + '@typescript-eslint/parser@8.52.0': + resolution: {integrity: sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/project-service@8.51.0': - resolution: {integrity: sha512-Luv/GafO07Z7HpiI7qeEW5NW8HUtZI/fo/kE0YbtQEFpJRUuR0ajcWfCE5bnMvL7QQFrmT/odMe8QZww8X2nfQ==} + '@typescript-eslint/project-service@8.52.0': + resolution: {integrity: sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/scope-manager@8.51.0': - resolution: {integrity: sha512-JhhJDVwsSx4hiOEQPeajGhCWgBMBwVkxC/Pet53EpBVs7zHHtayKefw1jtPaNRXpI9RA2uocdmpdfE7T+NrizA==} + '@typescript-eslint/scope-manager@8.52.0': + resolution: {integrity: sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/tsconfig-utils@8.51.0': - resolution: {integrity: sha512-Qi5bSy/vuHeWyir2C8u/uqGMIlIDu8fuiYWv48ZGlZ/k+PRPHtaAu7erpc7p5bzw2WNNSniuxoMSO4Ar6V9OXw==} + '@typescript-eslint/tsconfig-utils@8.52.0': + resolution: {integrity: sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.51.0': - resolution: {integrity: sha512-0XVtYzxnobc9K0VU7wRWg1yiUrw4oQzexCG2V2IDxxCxhqBMSMbjB+6o91A+Uc0GWtgjCa3Y8bi7hwI0Tu4n5Q==} + '@typescript-eslint/type-utils@8.52.0': + resolution: {integrity: sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/types@8.51.0': - resolution: {integrity: sha512-TizAvWYFM6sSscmEakjY3sPqGwxZRSywSsPEiuZF6d5GmGD9Gvlsv0f6N8FvAAA0CD06l3rIcWNbsN1e5F/9Ag==} + '@typescript-eslint/types@8.52.0': + resolution: {integrity: sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.51.0': - resolution: {integrity: sha512-1qNjGqFRmlq0VW5iVlcyHBbCjPB7y6SxpBkrbhNWMy/65ZoncXCEPJxkRZL8McrseNH6lFhaxCIaX+vBuFnRng==} + '@typescript-eslint/typescript-estree@8.52.0': + resolution: {integrity: sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/utils@8.51.0': - resolution: {integrity: sha512-11rZYxSe0zabiKaCP2QAwRf/dnmgFgvTmeDTtZvUvXG3UuAdg/GU02NExmmIXzz3vLGgMdtrIosI84jITQOxUA==} + '@typescript-eslint/utils@8.52.0': + resolution: {integrity: sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/visitor-keys@8.51.0': - resolution: {integrity: sha512-mM/JRQOzhVN1ykejrvwnBRV3+7yTKK8tVANVN3o1O0t0v7o+jqdVu9crPy5Y9dov15TJk/FTIgoUGHrTOVL3Zg==} + '@typescript-eslint/visitor-keys@8.52.0': + resolution: {integrity: sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -1541,8 +1541,8 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - axe-core@4.11.0: - resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + axe-core@4.11.1: + resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} axobject-query@4.1.0: @@ -1552,8 +1552,8 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - baseline-browser-mapping@2.9.11: - resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + baseline-browser-mapping@2.9.13: + resolution: {integrity: sha512-WhtvB2NG2wjr04+h77sg3klAIwrgOqnjS49GGudnUPGFFgg7G17y7Qecqp+2Dr5kUDxNRBca0SK7cG8JwzkWDQ==} hasBin: true bidi-js@1.0.3: @@ -1598,8 +1598,8 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} - caniuse-lite@1.0.30001762: - resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + caniuse-lite@1.0.30001763: + resolution: {integrity: sha512-mh/dGtq56uN98LlNX9qdbKnzINhX0QzhiWBFEkFfsFO4QyCvL8YegrJAazCwXIeqkIob8BlZPGM3xdnY+sgmvQ==} chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} @@ -1958,8 +1958,8 @@ packages: resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} engines: {node: '>=0.10'} esrecurse@4.3.0: @@ -3041,8 +3041,8 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} - ts-api-utils@2.3.0: - resolution: {integrity: sha512-6eg3Y9SF7SsAvGzRHQvvc1skDAhwI4YQ32ui1scxD1Ccr0G5qIIbUBT3pFTKX8kmWIQClHobtUdNuaBgwdfdWg==} + ts-api-utils@2.4.0: + resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} peerDependencies: typescript: '>=4.8.4' @@ -3311,39 +3311,39 @@ snapshots: '@babel/runtime@7.28.4': {} - '@biomejs/biome@2.3.10': + '@biomejs/biome@2.3.11': optionalDependencies: - '@biomejs/cli-darwin-arm64': 2.3.10 - '@biomejs/cli-darwin-x64': 2.3.10 - '@biomejs/cli-linux-arm64': 2.3.10 - '@biomejs/cli-linux-arm64-musl': 2.3.10 - '@biomejs/cli-linux-x64': 2.3.10 - '@biomejs/cli-linux-x64-musl': 2.3.10 - '@biomejs/cli-win32-arm64': 2.3.10 - '@biomejs/cli-win32-x64': 2.3.10 - - '@biomejs/cli-darwin-arm64@2.3.10': + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 + + '@biomejs/cli-darwin-arm64@2.3.11': optional: true - '@biomejs/cli-darwin-x64@2.3.10': + '@biomejs/cli-darwin-x64@2.3.11': optional: true - '@biomejs/cli-linux-arm64-musl@2.3.10': + '@biomejs/cli-linux-arm64-musl@2.3.11': optional: true - '@biomejs/cli-linux-arm64@2.3.10': + '@biomejs/cli-linux-arm64@2.3.11': optional: true - '@biomejs/cli-linux-x64-musl@2.3.10': + '@biomejs/cli-linux-x64-musl@2.3.11': optional: true - '@biomejs/cli-linux-x64@2.3.10': + '@biomejs/cli-linux-x64@2.3.11': optional: true - '@biomejs/cli-win32-arm64@2.3.10': + '@biomejs/cli-win32-arm64@2.3.11': optional: true - '@biomejs/cli-win32-x64@2.3.10': + '@biomejs/cli-win32-x64@2.3.11': optional: true '@csstools/color-helpers@5.1.0': {} @@ -3364,17 +3364,17 @@ snapshots: dependencies: '@csstools/css-tokenizer': 3.0.4 - '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + '@csstools/css-syntax-patches-for-csstree@1.0.23': {} '@csstools/css-tokenizer@3.0.4': {} - '@emnapi/core@1.7.1': + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 optional: true @@ -3462,7 +3462,7 @@ snapshots: '@esbuild/win32-x64@0.27.2': optional: true - '@eslint-community/eslint-utils@4.9.0(eslint@9.39.2(jiti@1.21.7))': + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@1.21.7))': dependencies: eslint: 9.39.2(jiti@1.21.7) eslint-visitor-keys: 3.4.3 @@ -3638,7 +3638,7 @@ snapshots: '@img/sharp-wasm32@0.34.5': dependencies: - '@emnapi/runtime': 1.7.1 + '@emnapi/runtime': 1.8.1 optional: true '@img/sharp-win32-arm64@0.34.5': @@ -3684,8 +3684,8 @@ snapshots: '@napi-rs/wasm-runtime@0.2.12': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 '@tybys/wasm-util': 0.10.1 optional: true @@ -4138,95 +4138,95 @@ snapshots: dependencies: '@types/geojson': 7946.0.16 - '@typescript-eslint/eslint-plugin@8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/type-utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/type-utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 eslint: 9.39.2(jiti@1.21.7) ignore: 7.0.5 natural-compare: 1.4.0 - ts-api-utils: 2.3.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.51.0(typescript@5.9.3)': + '@typescript-eslint/project-service@8.52.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) - '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.51.0': + '@typescript-eslint/scope-manager@8.52.0': dependencies: - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 - '@typescript-eslint/tsconfig-utils@8.51.0(typescript@5.9.3)': + '@typescript-eslint/tsconfig-utils@8.52.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) + '@typescript-eslint/utils': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.2(jiti@1.21.7) - ts-api-utils: 2.3.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.51.0': {} + '@typescript-eslint/types@8.52.0': {} - '@typescript-eslint/typescript-estree@8.51.0(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.52.0(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.51.0(typescript@5.9.3) - '@typescript-eslint/tsconfig-utils': 8.51.0(typescript@5.9.3) - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/visitor-keys': 8.51.0 + '@typescript-eslint/project-service': 8.52.0(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.52.0(typescript@5.9.3) + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/visitor-keys': 8.52.0 debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 - ts-api-utils: 2.3.0(typescript@5.9.3) + ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': + '@typescript-eslint/utils@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) - '@typescript-eslint/scope-manager': 8.51.0 - '@typescript-eslint/types': 8.51.0 - '@typescript-eslint/typescript-estree': 8.51.0(typescript@5.9.3) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) + '@typescript-eslint/scope-manager': 8.52.0 + '@typescript-eslint/types': 8.52.0 + '@typescript-eslint/typescript-estree': 8.52.0(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.51.0': + '@typescript-eslint/visitor-keys@8.52.0': dependencies: - '@typescript-eslint/types': 8.51.0 + '@typescript-eslint/types': 8.52.0 eslint-visitor-keys: 4.2.1 '@unrs/resolver-binding-android-arm-eabi@1.11.1': @@ -4455,7 +4455,7 @@ snapshots: autoprefixer@10.4.23(postcss@8.5.6): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001762 + caniuse-lite: 1.0.30001763 fraction.js: 5.3.4 picocolors: 1.1.1 postcss: 8.5.6 @@ -4465,13 +4465,13 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - axe-core@4.11.0: {} + axe-core@4.11.1: {} axobject-query@4.1.0: {} balanced-match@1.0.2: {} - baseline-browser-mapping@2.9.11: {} + baseline-browser-mapping@2.9.13: {} bidi-js@1.0.3: dependencies: @@ -4494,8 +4494,8 @@ snapshots: browserslist@4.28.1: dependencies: - baseline-browser-mapping: 2.9.11 - caniuse-lite: 1.0.30001762 + baseline-browser-mapping: 2.9.13 + caniuse-lite: 1.0.30001763 electron-to-chromium: 1.5.267 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) @@ -4521,7 +4521,7 @@ snapshots: camelcase-css@2.0.1: {} - caniuse-lite@1.0.30001762: {} + caniuse-lite@1.0.30001763: {} chai@6.2.2: {} @@ -4595,7 +4595,7 @@ snapshots: cssstyle@5.3.7: dependencies: '@asamuzakjp/css-color': 4.1.1 - '@csstools/css-syntax-patches-for-csstree': 1.0.22 + '@csstools/css-syntax-patches-for-csstree': 1.0.23 css-tree: 3.1.0 lru-cache: 11.2.4 @@ -4826,12 +4826,12 @@ snapshots: dependencies: '@next/eslint-plugin-next': 15.5.9 '@rushstack/eslint-patch': 1.15.0 - '@typescript-eslint/eslint-plugin': 8.51.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.52.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@1.21.7)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.2(jiti@1.21.7)) @@ -4872,7 +4872,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -4887,33 +4887,33 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 4.4.4(eslint-plugin-import@2.32.0)(eslint@9.39.2(jiti@1.21.7)) transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4924,7 +4924,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4936,13 +4936,13 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -4953,7 +4953,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@4.4.4)(eslint@9.39.2(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -4965,7 +4965,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.51.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) + '@typescript-eslint/parser': 8.52.0(eslint@9.39.2(jiti@1.21.7))(typescript@5.9.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -4977,7 +4977,7 @@ snapshots: array-includes: 3.1.9 array.prototype.flatmap: 1.3.3 ast-types-flow: 0.0.8 - axe-core: 4.11.0 + axe-core: 4.11.1 axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 @@ -5027,7 +5027,7 @@ snapshots: eslint@9.39.2(jiti@1.21.7): dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.39.2(jiti@1.21.7)) + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.2(jiti@1.21.7)) '@eslint-community/regexpp': 4.12.2 '@eslint/config-array': 0.21.1 '@eslint/config-helpers': 0.4.2 @@ -5047,7 +5047,7 @@ snapshots: eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 espree: 10.4.0 - esquery: 1.6.0 + esquery: 1.7.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 file-entry-cache: 8.0.0 @@ -5072,7 +5072,7 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 - esquery@1.6.0: + esquery@1.7.0: dependencies: estraverse: 5.3.0 @@ -5624,7 +5624,7 @@ snapshots: dependencies: '@next/env': 15.5.9 '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001762 + caniuse-lite: 1.0.30001763 postcss: 8.4.31 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) @@ -6280,7 +6280,7 @@ snapshots: dependencies: punycode: 2.3.1 - ts-api-utils@2.3.0(typescript@5.9.3): + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 From 958a1c418c500434826c77a674f2d724e958e13e Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Fri, 9 Jan 2026 16:06:07 -0500 Subject: [PATCH 2/6] Refactor: modularize components and add centralized config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create centralized geo config (src/config/map.config.ts) for future multi-geography support - Extract 9 sidebar components from MapLegend.tsx (710 → 240 lines) - Create useToast and useMapResize custom hooks - Rename utils.ts → map.ts for clarity - Remove duplicate CSS from globals.css - Add 12 new tests (useToast, BikeRoutes, map.config) Co-Authored-By: Claude Opus 4.5 --- next-env.d.ts | 1 + src/app/globals.css | 62 --- src/components/Map.tsx | 210 +++----- src/components/MapLegend.tsx | 497 +----------------- src/components/sidebar/AttractionsList.tsx | 51 ++ src/components/sidebar/BikeRentalList.tsx | 119 +++++ src/components/sidebar/BikeResourcesList.tsx | 51 ++ src/components/sidebar/BikeRoutes.test.tsx | 114 ++++ src/components/sidebar/BikeRoutes.tsx | 37 ++ src/components/sidebar/Footer.tsx | 14 + src/components/sidebar/InformationSection.tsx | 78 +++ src/components/sidebar/MapLayers.tsx | 80 +++ src/components/sidebar/SidebarHeader.tsx | 9 + src/components/sidebar/ToggleSwitch.tsx | 13 + src/components/sidebar/index.ts | 22 + src/components/sidebar/types.ts | 49 ++ src/config/map.config.test.ts | 58 ++ src/config/map.config.ts | 82 +++ src/data/gbfs.ts | 9 +- src/hooks/index.ts | 3 + src/hooks/useLocationTracking.ts | 153 ++++++ src/hooks/useMapResize.ts | 33 ++ src/hooks/useToast.test.ts | 119 +++++ src/hooks/useToast.ts | 39 ++ src/utils/{utils.test.ts => map.test.ts} | 2 +- src/utils/{utils.ts => map.ts} | 0 26 files changed, 1209 insertions(+), 696 deletions(-) create mode 100644 src/components/sidebar/AttractionsList.tsx create mode 100644 src/components/sidebar/BikeRentalList.tsx create mode 100644 src/components/sidebar/BikeResourcesList.tsx create mode 100644 src/components/sidebar/BikeRoutes.test.tsx create mode 100644 src/components/sidebar/BikeRoutes.tsx create mode 100644 src/components/sidebar/Footer.tsx create mode 100644 src/components/sidebar/InformationSection.tsx create mode 100644 src/components/sidebar/MapLayers.tsx create mode 100644 src/components/sidebar/SidebarHeader.tsx create mode 100644 src/components/sidebar/ToggleSwitch.tsx create mode 100644 src/components/sidebar/index.ts create mode 100644 src/components/sidebar/types.ts create mode 100644 src/config/map.config.test.ts create mode 100644 src/config/map.config.ts create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/useLocationTracking.ts create mode 100644 src/hooks/useMapResize.ts create mode 100644 src/hooks/useToast.test.ts create mode 100644 src/hooks/useToast.ts rename src/utils/{utils.test.ts => map.test.ts} (99%) rename src/utils/{utils.ts => map.ts} (100%) diff --git a/next-env.d.ts b/next-env.d.ts index 3cd7048..36a4fe4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,7 @@ /// /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/globals.css b/src/app/globals.css index ee69c4f..7796369 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -40,25 +40,6 @@ body > div, position: fixed; } -/* Map container styles */ -.map-wrapper { - position: fixed; - width: 100%; - height: 100%; - overflow: hidden; -} - -.map-container { - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; - width: 100%; - height: 100%; - z-index: 1; -} - /* Fix for Mapbox controls - ensure they're visible */ .mapboxgl-ctrl-top-right { z-index: 1500 !important; @@ -94,46 +75,3 @@ body > div, z-index: 20; max-width: 80%; } - -/* Location Marker Styles */ -.current-location-marker { - width: 24px; - height: 24px; - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -.location-dot { - width: 14px; - height: 14px; - background-color: #4285f4; - border: 2px solid white; - border-radius: 50%; - position: relative; - z-index: 2; - box-shadow: 0 0 3px rgba(0, 0, 0, 0.3); -} - -.location-pulse { - width: 32px; - height: 32px; - background-color: #4285f4; - border-radius: 50%; - position: absolute; - animation: pulse 1.5s ease-out infinite; - opacity: 0.4; - z-index: 1; -} - -@keyframes pulse { - 0% { - transform: scale(0.5); - opacity: 0.4; - } - 100% { - transform: scale(2); - opacity: 0; - } -} diff --git a/src/components/Map.tsx b/src/components/Map.tsx index d96dd95..df98a78 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -15,6 +15,7 @@ import { ensureFontAwesomeLoaded, MarkerManager, } from '@/components/MapMarkers'; +import { useToast, useMapResize } from '@/hooks'; import { fetchStationInformation, fetchStationStatus, @@ -27,14 +28,11 @@ import { calculateZoomForBounds, calculateRouteBounds, findLocationInArray, -} from '@/utils/utils'; +} from '@/utils/map'; +import { mapConfig } from '@/config/map.config'; -// Initialize Mapbox access token -mapboxgl.accessToken = - 'pk.eyJ1Ijoic3d1bGxlciIsImEiOiJjbThyZTVuMzEwMTZwMmpvdTRzM3JpMGlhIn0.CF5lzLSkkfO-c0qt6a168A'; - -// Debug location coordinates - set to null to use real location -const DEBUG_LOCATION: [number, number] = [-85.306739, 35.059623]; // Outdoor Chattanooga +// Initialize Mapbox access token from config +mapboxgl.accessToken = mapConfig.mapbox.accessToken; // MapboxMap component - isolated from UI state changes const MapboxMap = memo(function MapboxMap() { @@ -52,8 +50,14 @@ const MapboxMap = memo(function MapboxMap() { const [showAttractions, setShowAttractions] = useState(false); const [showBikeResources, setShowBikeResources] = useState(false); const [showBikeRentals, setShowBikeRentals] = useState(false); - const [toastMessage, setToastMessage] = useState(null); - const [toastFadingOut, setToastFadingOut] = useState(false); + + // Use custom hooks + const { + message: toastMessage, + isFadingOut: toastFadingOut, + showToast, + } = useToast(); + useMapResize({ map }); // Create location marker function initializeLocationMarker() { @@ -121,59 +125,61 @@ const MapboxMap = memo(function MapboxMap() { } // Handle route selection events - outside the map initialization - const handleRouteSelect = useCallback((event: CustomEvent) => { - if (!map.current) { - return; - } - - const { routeId } = event.detail; - - // Find the selected route to get its name - const selectedRoute = bikeRoutes.find((route) => route.id === routeId); + const handleRouteSelect = useCallback( + (event: CustomEvent) => { + if (!map.current) { + return; + } - // Show toast with route name - if (selectedRoute) { - setToastMessage(selectedRoute.name); - setToastFadingOut(false); // Reset fade-out state for new toast - } + const { routeId } = event.detail; - // Update opacities for all routes - updateRouteOpacity(map.current, bikeRoutes, routeId, { - selected: 0.8, - unselected: 0.2, - }); + // Find the selected route to get its name + const selectedRoute = bikeRoutes.find((route) => route.id === routeId); - if (selectedRoute?.bounds) { - const bounds = selectedRoute.bounds; + // Show toast with route name + if (selectedRoute) { + showToast(selectedRoute.name); + } - try { - // Calculate the center of the bounds - const centerLng = (bounds.getWest() + bounds.getEast()) / 2; - const centerLat = (bounds.getNorth() + bounds.getSouth()) / 2; + // Update opacities for all routes + updateRouteOpacity(map.current, bikeRoutes, routeId, { + selected: 0.8, + unselected: 0.2, + }); - // Calculate zoom level based on bounds and device type - const isMobile = window.innerWidth <= 768; - const zoom = calculateZoomForBounds(bounds, isMobile); + if (selectedRoute?.bounds) { + const bounds = selectedRoute.bounds; - // Use flyTo which tends to be more reliable - map.current.flyTo({ - center: [centerLng, centerLat], - zoom: zoom, - essential: true, - duration: 1000, - }); + try { + // Calculate the center of the bounds + const centerLng = (bounds.getWest() + bounds.getEast()) / 2; + const centerLat = (bounds.getNorth() + bounds.getSouth()) / 2; + + // Calculate zoom level based on bounds and device type + const isMobile = window.innerWidth <= 768; + const zoom = calculateZoomForBounds(bounds, isMobile); + + // Use flyTo which tends to be more reliable + map.current.flyTo({ + center: [centerLng, centerLat], + zoom: zoom, + essential: true, + duration: 1000, + }); - // Force a resize to ensure the map is rendered properly - setTimeout(() => { - if (map.current) { - map.current.resize(); - } - }, 100); - } catch (error) { - console.error('Error flying to route:', error); + // Force a resize to ensure the map is rendered properly + setTimeout(() => { + if (map.current) { + map.current.resize(); + } + }, 100); + } catch (error) { + console.error('Error flying to route:', error); + } } - } - }, []); + }, + [showToast], + ); // Handle layer toggle events const handleLayerToggle = useCallback(async (event: CustomEvent) => { @@ -391,27 +397,6 @@ const MapboxMap = memo(function MapboxMap() { [showAttractions, showBikeResources, showBikeRentals], ); - // Auto-dismiss toast after 3 seconds with fade-out - useEffect(() => { - if (toastMessage && !toastFadingOut) { - // After 2.7 seconds, start fade-out animation (300ms before removal) - const fadeOutTimer = setTimeout(() => { - setToastFadingOut(true); - }, 2700); - - // After 3 seconds total, remove the toast - const removeTimer = setTimeout(() => { - setToastMessage(null); - setToastFadingOut(false); - }, 3000); - - return () => { - clearTimeout(fadeOutTimer); - clearTimeout(removeTimer); - }; - } - }, [toastMessage, toastFadingOut]); - // Set up event listeners for map layers and location centering useEffect(() => { // Create stable wrapper functions that don't change between renders @@ -457,11 +442,11 @@ const MapboxMap = memo(function MapboxMap() { const newMap = new mapboxgl.Map({ container: mapContainer.current as HTMLElement, - style: 'mapbox://styles/swuller/cm91zy289001p01qu4cdsdcgt', - center: DEBUG_LOCATION, // Default to Chattanooga - zoom: 14.89, - pitch: -22.4, - bearing: 11, + style: mapConfig.mapbox.styleUrl, + center: mapConfig.defaultView.center, + zoom: mapConfig.defaultView.zoom, + pitch: mapConfig.defaultView.pitch, + bearing: mapConfig.defaultView.bearing, antialias: true, }); @@ -604,67 +589,6 @@ const MapboxMap = memo(function MapboxMap() { }; }, []); // Empty dependency array - only run once on mount - // Add resize event listener - useEffect(() => { - const handleResize = () => { - if (map.current) { - map.current.resize(); - } - }; - - window.addEventListener('resize', handleResize); - - // Listen for sidebar toggle events - const handleSidebarToggle = () => { - setTimeout(() => { - if (map.current) { - map.current.resize(); - } - }, 300); // Longer delay to wait for transition - }; - - window.addEventListener('sidebar-toggle', handleSidebarToggle); - - return () => { - window.removeEventListener('resize', handleResize); - window.removeEventListener('sidebar-toggle', handleSidebarToggle); - }; - }, []); - - // Add cleanup for component unmount - useEffect(() => { - // Capture refs for cleanup - const attractionMarkersRef = attractionMarkers.current; - const bikeResourceMarkersRef = bikeResourceMarkers.current; - const bikeRentalMarkersRef = bikeRentalMarkers.current; - - return () => { - // Clean up all markers before unmounting - if (locationMarker.current) { - locationMarker.current.remove(); - } - - attractionMarkersRef.clear(); - bikeResourceMarkersRef.clear(); - bikeRentalMarkersRef.clear(); - - if (map.current) { - map.current.remove(); - map.current = null; - } - - if (watchId.current !== null) { - navigator.geolocation.clearWatch(watchId.current); - watchId.current = null; - } - - if (locationWatch.current) { - clearInterval(locationWatch.current); - locationWatch.current = undefined; - } - }; - }, []); - const setLocationWatch = (value: boolean) => { setWatchingLocation(value); @@ -717,8 +641,8 @@ const MapboxMap = memo(function MapboxMap() { )} - {/* Debug mode toggle */} - {DEBUG_LOCATION && ( + {/* Location tracking toggle */} + {mapConfig.debug.showLocationTracker && (
{ diff --git a/src/components/MapLegend.tsx b/src/components/MapLegend.tsx index cabc997..5ab6e25 100644 --- a/src/components/MapLegend.tsx +++ b/src/components/MapLegend.tsx @@ -1,492 +1,21 @@ 'use client'; -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -import { - faMapMarkerAlt, - faTimes, - faLocationArrow, - faBicycle, - faLayerGroup, -} from '@fortawesome/free-solid-svg-icons'; -import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; -import { - bikeRoutes, - mapFeatures, - bikeResources, - localResources, - type BikeRentalLocation, -} from '@/data/geo_data'; -import { - fetchStationInformation, - fetchStationStatus, - gbfsToBikeRentalLocation, -} from '@/data/gbfs'; +import { faTimes, faLayerGroup } from '@fortawesome/free-solid-svg-icons'; import './map-legend.css'; -// Define interfaces for various component props -interface ToggleSwitchProps { - isActive: boolean; - color?: string; -} - -interface BikeRoutesProps { - selectedRoute: string | null; - onRouteSelect: (routeId: string) => void; -} - -interface MapLayersProps { - showAttractions: boolean; - showBikeResources: boolean; - showBikeRentals: boolean; - onToggleAttractions: () => void; - onToggleBikeResources: () => void; - onToggleBikeRentals: () => void; -} - -interface LocationProps { - name: string; - description: string; - icon: IconDefinition; - latitude?: number; - longitude?: number; - address?: string; -} - -interface AttractionsListProps { - show: boolean; - onCenterLocation: (location: LocationProps) => void; -} - -interface BikeResourcesListProps { - show: boolean; - onCenterLocation: (location: LocationProps) => void; -} - -interface ExternalLinkProps { - href: string; - children: React.ReactNode; -} - -// Toggle Switch Component -const ToggleSwitch: React.FC = ({ isActive }) => ( -
-
-
-); - -// Header Component -const SidebarHeader = () => ( -
-

- Bike Chatt -

-
-); - -// BikeRoutes Component -const BikeRoutes: React.FC = ({ - selectedRoute, - onRouteSelect, -}) => ( -
-

Pick a Loop

-
- {bikeRoutes.map((route) => ( -
onRouteSelect(route.id)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onRouteSelect(route.id); - } - }} - role="button" - tabIndex={0} - className={`route-item ${selectedRoute === route.id ? 'route-item-selected' : ''}`} - > -
-
- {route.name} -
-
{route.description}
-
- ))} -
-
-); - -// MapLayers Component -const MapLayers: React.FC = ({ - showAttractions, - showBikeResources, - showBikeRentals, - onToggleAttractions, - onToggleBikeResources, - onToggleBikeRentals, -}) => ( -
-

Map Layers

-
- {/* Attractions Layer Toggle */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleAttractions(); - } - }} - role="button" - tabIndex={0} - className="layer-toggle" - > -
- - Attractions -
- -
- - {/* Bike Resources Layer Toggle */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleBikeResources(); - } - }} - role="button" - tabIndex={0} - className="layer-toggle" - > -
- - Bike Resources -
- -
- - {/* Bike Rentals Layer Toggle */} -
{ - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onToggleBikeRentals(); - } - }} - role="button" - tabIndex={0} - className="layer-toggle" - > -
- - Bike Rentals -
- -
-
-
-); - -// AttractionsList Component -const AttractionsList: React.FC = ({ - show, - onCenterLocation, -}) => ( -
-

Attractions

-
- {mapFeatures.map((location) => ( -
onCenterLocation(location)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onCenterLocation(location); - } - }} - role="button" - tabIndex={0} - > -
-
- -
- {location.name} -
-
- {location.description} -
- -
-
-
- ))} -
-
-); - -// BikeResourcesList Component -const BikeResourcesList: React.FC = ({ - show, - onCenterLocation, -}) => ( -
-

Bike Resources

-
- {bikeResources.map((location) => ( -
onCenterLocation(location)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onCenterLocation(location); - } - }} - role="button" - tabIndex={0} - > -
-
- -
- {location.name} -
-
- {location.description} -
- -
-
-
- ))} -
-
-); - -// BikeRentalList Component -const BikeRentalList: React.FC<{ - show: boolean; - onCenterLocation: (location: LocationProps) => void; -}> = ({ show, onCenterLocation }) => { - const [rentalLocations, setRentalLocations] = useState( - [], - ); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchRentalLocations = async () => { - setIsLoading(true); - setError(null); - try { - const [stations, statuses] = await Promise.all([ - fetchStationInformation(), - fetchStationStatus(), - ]); - - const statusMap = new Map( - statuses.map((status) => [status.station_id, status]), - ); - const locations = stations.map((station) => - gbfsToBikeRentalLocation(station, statusMap.get(station.station_id)), - ); - setRentalLocations(locations); - } catch (err) { - setError('Failed to load bike rental locations'); - console.error('Error fetching bike rental data:', err); - } finally { - setIsLoading(false); - } - }; - - if (show) { - fetchRentalLocations(); - } - }, [show]); - - return ( -
-

Bike Rentals

- {isLoading && ( -
Loading bike rental locations...
- )} - {error &&
{error}
} - {!isLoading && !error && ( -
- {rentalLocations.map((location) => ( -
onCenterLocation(location)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - onCenterLocation(location); - } - }} - role="button" - tabIndex={0} - > -
-
- -
- {location.name} -
-
- {location.description} -
- -
-
-
- {location.rentalType} - {location.price} - {location.hours} - {location.availableBikes !== undefined && ( - - Bikes: {location.availableBikes} - - )} - {location.availableDocks !== undefined && ( - - Docks: {location.availableDocks} - - )} - {location.isChargingStation && ( - Charging Available - )} -
-
- ))} -
- )} -
- ); -}; - -// Link Component -const ExternalLink: React.FC = ({ href, children }) => ( - - {children} - -); - -// Helper function to render description with inline link -const renderDescriptionWithLink = ( - description: string, - url: string, - linkText: string, -) => { - const parts = description.split(linkText); - if (parts.length === 2) { - return ( - <> - {parts[0]} - {linkText} - {parts[1]} - - ); - } - return description; -}; - -// InformationSection Component -const InformationSection = () => ( -
-

Information

-
- {localResources.map((resource) => ( -
-
-
- -
- {resource.name} -
-
- {renderDescriptionWithLink( - resource.description, - resource.url, - resource.description.includes('iFixit') - ? 'iFixit' - : resource.name, - )} -
- {resource.secondaryDescription && - resource.secondaryUrl && - resource.secondaryLinkText && ( -
- {renderDescriptionWithLink( - resource.secondaryDescription, - resource.secondaryUrl, - resource.secondaryLinkText, - )} -
- )} -
- ))} -
-
-); - -// Footer Component -const Footer = () => ( -
-

Get Out and Have Fun

-

- Pedal your way through Chattanooga's best spots—feel the river - breeze, roll up to the Zoo for an up-close animal encounter, explore the - Aquarium's underwater wonders, and step back in time at the Railroad - Museum. Grab your bike, gather friends, and enjoy the ride! -

-

© {new Date().getFullYear()} BikeMap

-
-); +import { + SidebarHeader, + BikeRoutes, + MapLayers, + AttractionsList, + BikeResourcesList, + BikeRentalList, + InformationSection, + Footer, + type LocationProps, +} from './sidebar'; // Main provider component export function MapLegendProvider({ children }: { children: React.ReactNode }) { diff --git a/src/components/sidebar/AttractionsList.tsx b/src/components/sidebar/AttractionsList.tsx new file mode 100644 index 0000000..2346bd3 --- /dev/null +++ b/src/components/sidebar/AttractionsList.tsx @@ -0,0 +1,51 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLocationArrow } from '@fortawesome/free-solid-svg-icons'; +import { mapFeatures } from '@/data/geo_data'; +import type { AttractionsListProps } from './types'; + +export function AttractionsList({ + show, + onCenterLocation, +}: AttractionsListProps) { + return ( +
+

Attractions

+
+ {mapFeatures.map((location) => ( +
onCenterLocation(location)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCenterLocation(location); + } + }} + role="button" + tabIndex={0} + > +
+
+ +
+ {location.name} +
+
+ {location.description} +
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/sidebar/BikeRentalList.tsx b/src/components/sidebar/BikeRentalList.tsx new file mode 100644 index 0000000..303ca75 --- /dev/null +++ b/src/components/sidebar/BikeRentalList.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLocationArrow } from '@fortawesome/free-solid-svg-icons'; +import { + fetchStationInformation, + fetchStationStatus, + gbfsToBikeRentalLocation, + type BikeRentalLocation, +} from '@/data/gbfs'; +import type { BikeRentalListProps } from './types'; + +export function BikeRentalList({ + show, + onCenterLocation, +}: BikeRentalListProps) { + const [rentalLocations, setRentalLocations] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchRentalLocations = async () => { + setIsLoading(true); + setError(null); + try { + const [stations, statuses] = await Promise.all([ + fetchStationInformation(), + fetchStationStatus(), + ]); + + const statusMap = new Map( + statuses.map((status) => [status.station_id, status]), + ); + const locations = stations.map((station) => + gbfsToBikeRentalLocation(station, statusMap.get(station.station_id)), + ); + setRentalLocations(locations); + } catch (err) { + setError('Failed to load bike rental locations'); + console.error('Error fetching bike rental data:', err); + } finally { + setIsLoading(false); + } + }; + + if (show) { + fetchRentalLocations(); + } + }, [show]); + + return ( +
+

Bike Rentals

+ {isLoading && ( +
Loading bike rental locations...
+ )} + {error &&
{error}
} + {!isLoading && !error && ( +
+ {rentalLocations.map((location) => ( +
onCenterLocation(location)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCenterLocation(location); + } + }} + role="button" + tabIndex={0} + > +
+
+ +
+ {location.name} +
+
+ {location.description} +
+ +
+
+
+ {location.rentalType} + {location.price} + {location.hours} + {location.availableBikes !== undefined && ( + + Bikes: {location.availableBikes} + + )} + {location.availableDocks !== undefined && ( + + Docks: {location.availableDocks} + + )} + {location.isChargingStation && ( + Charging Available + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/sidebar/BikeResourcesList.tsx b/src/components/sidebar/BikeResourcesList.tsx new file mode 100644 index 0000000..0615d55 --- /dev/null +++ b/src/components/sidebar/BikeResourcesList.tsx @@ -0,0 +1,51 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faLocationArrow } from '@fortawesome/free-solid-svg-icons'; +import { bikeResources } from '@/data/geo_data'; +import type { BikeResourcesListProps } from './types'; + +export function BikeResourcesList({ + show, + onCenterLocation, +}: BikeResourcesListProps) { + return ( +
+

Bike Resources

+
+ {bikeResources.map((location) => ( +
onCenterLocation(location)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onCenterLocation(location); + } + }} + role="button" + tabIndex={0} + > +
+
+ +
+ {location.name} +
+
+ {location.description} +
+ +
+
+
+ ))} +
+
+ ); +} diff --git a/src/components/sidebar/BikeRoutes.test.tsx b/src/components/sidebar/BikeRoutes.test.tsx new file mode 100644 index 0000000..8236c04 --- /dev/null +++ b/src/components/sidebar/BikeRoutes.test.tsx @@ -0,0 +1,114 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { BikeRoutes } from './BikeRoutes'; + +// Mock the geo_data module +vi.mock('@/data/geo_data', () => ({ + bikeRoutes: [ + { + id: 'route-1', + name: 'Test Route 1', + color: '#FF0000', + description: 'Description for route 1', + }, + { + id: 'route-2', + name: 'Test Route 2', + color: '#00FF00', + description: 'Description for route 2', + }, + ], +})); + +describe('BikeRoutes', () => { + it('should render all routes', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + expect(screen.getByText('Pick a Loop')).toBeInTheDocument(); + expect(screen.getByText('Test Route 1')).toBeInTheDocument(); + expect(screen.getByText('Test Route 2')).toBeInTheDocument(); + expect(screen.getByText('Description for route 1')).toBeInTheDocument(); + expect(screen.getByText('Description for route 2')).toBeInTheDocument(); + }); + + it('should call onRouteSelect when route is clicked', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + const route1Button = screen + .getByText('Test Route 1') + .closest('[role="button"]'); + fireEvent.click(route1Button!); + + expect(mockOnRouteSelect).toHaveBeenCalledWith('route-1'); + }); + + it('should call onRouteSelect on Enter key press', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + const route2Button = screen + .getByText('Test Route 2') + .closest('[role="button"]'); + fireEvent.keyDown(route2Button!, { key: 'Enter' }); + + expect(mockOnRouteSelect).toHaveBeenCalledWith('route-2'); + }); + + it('should call onRouteSelect on Space key press', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + const route1Button = screen + .getByText('Test Route 1') + .closest('[role="button"]'); + fireEvent.keyDown(route1Button!, { key: ' ' }); + + expect(mockOnRouteSelect).toHaveBeenCalledWith('route-1'); + }); + + it('should highlight selected route', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + const route1Button = screen + .getByText('Test Route 1') + .closest('[role="button"]'); + const route2Button = screen + .getByText('Test Route 2') + .closest('[role="button"]'); + + expect(route1Button).toHaveClass('route-item-selected'); + expect(route2Button).not.toHaveClass('route-item-selected'); + }); + + it('should display route color indicators', () => { + const mockOnRouteSelect = vi.fn(); + + render( + , + ); + + const colorIndicators = document.querySelectorAll('.route-color-indicator'); + expect(colorIndicators).toHaveLength(2); + expect(colorIndicators[0]).toHaveStyle({ backgroundColor: '#FF0000' }); + expect(colorIndicators[1]).toHaveStyle({ backgroundColor: '#00FF00' }); + }); +}); diff --git a/src/components/sidebar/BikeRoutes.tsx b/src/components/sidebar/BikeRoutes.tsx new file mode 100644 index 0000000..bdced0e --- /dev/null +++ b/src/components/sidebar/BikeRoutes.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { bikeRoutes } from '@/data/geo_data'; +import type { BikeRoutesProps } from './types'; + +export function BikeRoutes({ selectedRoute, onRouteSelect }: BikeRoutesProps) { + return ( +
+

Pick a Loop

+
+ {bikeRoutes.map((route) => ( +
onRouteSelect(route.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onRouteSelect(route.id); + } + }} + role="button" + tabIndex={0} + className={`route-item ${selectedRoute === route.id ? 'route-item-selected' : ''}`} + > +
+
+ {route.name} +
+
{route.description}
+
+ ))} +
+
+ ); +} diff --git a/src/components/sidebar/Footer.tsx b/src/components/sidebar/Footer.tsx new file mode 100644 index 0000000..f017a0e --- /dev/null +++ b/src/components/sidebar/Footer.tsx @@ -0,0 +1,14 @@ +export function Footer() { + return ( +
+

Get Out and Have Fun

+

+ Pedal your way through Chattanooga's best spots—feel the river + breeze, roll up to the Zoo for an up-close animal encounter, explore the + Aquarium's underwater wonders, and step back in time at the + Railroad Museum. Grab your bike, gather friends, and enjoy the ride! +

+

© {new Date().getFullYear()} BikeMap

+
+ ); +} diff --git a/src/components/sidebar/InformationSection.tsx b/src/components/sidebar/InformationSection.tsx new file mode 100644 index 0000000..c69719c --- /dev/null +++ b/src/components/sidebar/InformationSection.tsx @@ -0,0 +1,78 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { localResources } from '@/data/geo_data'; +import type { ExternalLinkProps } from './types'; + +function ExternalLink({ href, children }: ExternalLinkProps) { + return ( + + {children} + + ); +} + +function renderDescriptionWithLink( + description: string, + url: string, + linkText: string, +) { + const parts = description.split(linkText); + if (parts.length === 2) { + return ( + <> + {parts[0]} + {linkText} + {parts[1]} + + ); + } + return description; +} + +export function InformationSection() { + return ( +
+

Information

+
+ {localResources.map((resource) => ( +
+
+
+ +
+ {resource.name} +
+
+ {renderDescriptionWithLink( + resource.description, + resource.url, + resource.description.includes('iFixit') + ? 'iFixit' + : resource.name, + )} +
+ {resource.secondaryDescription && + resource.secondaryUrl && + resource.secondaryLinkText && ( +
+ {renderDescriptionWithLink( + resource.secondaryDescription, + resource.secondaryUrl, + resource.secondaryLinkText, + )} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/sidebar/MapLayers.tsx b/src/components/sidebar/MapLayers.tsx new file mode 100644 index 0000000..fcabc70 --- /dev/null +++ b/src/components/sidebar/MapLayers.tsx @@ -0,0 +1,80 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faMapMarkerAlt, faBicycle } from '@fortawesome/free-solid-svg-icons'; +import { ToggleSwitch } from './ToggleSwitch'; +import type { MapLayersProps } from './types'; + +export function MapLayers({ + showAttractions, + showBikeResources, + showBikeRentals, + onToggleAttractions, + onToggleBikeResources, + onToggleBikeRentals, +}: MapLayersProps) { + return ( +
+

Map Layers

+
+ {/* Attractions Layer Toggle */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleAttractions(); + } + }} + role="button" + tabIndex={0} + className="layer-toggle" + > +
+ + Attractions +
+ +
+ + {/* Bike Resources Layer Toggle */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleBikeResources(); + } + }} + role="button" + tabIndex={0} + className="layer-toggle" + > +
+ + Bike Resources +
+ +
+ + {/* Bike Rentals Layer Toggle */} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onToggleBikeRentals(); + } + }} + role="button" + tabIndex={0} + className="layer-toggle" + > +
+ + Bike Rentals +
+ +
+
+
+ ); +} diff --git a/src/components/sidebar/SidebarHeader.tsx b/src/components/sidebar/SidebarHeader.tsx new file mode 100644 index 0000000..8402b91 --- /dev/null +++ b/src/components/sidebar/SidebarHeader.tsx @@ -0,0 +1,9 @@ +export function SidebarHeader() { + return ( +
+

+ Bike Chatt +

+
+ ); +} diff --git a/src/components/sidebar/ToggleSwitch.tsx b/src/components/sidebar/ToggleSwitch.tsx new file mode 100644 index 0000000..5111ee0 --- /dev/null +++ b/src/components/sidebar/ToggleSwitch.tsx @@ -0,0 +1,13 @@ +import type { ToggleSwitchProps } from './types'; + +export function ToggleSwitch({ isActive }: ToggleSwitchProps) { + return ( +
+
+
+ ); +} diff --git a/src/components/sidebar/index.ts b/src/components/sidebar/index.ts new file mode 100644 index 0000000..b254dd4 --- /dev/null +++ b/src/components/sidebar/index.ts @@ -0,0 +1,22 @@ +// Sidebar components +export { ToggleSwitch } from './ToggleSwitch'; +export { SidebarHeader } from './SidebarHeader'; +export { BikeRoutes } from './BikeRoutes'; +export { MapLayers } from './MapLayers'; +export { AttractionsList } from './AttractionsList'; +export { BikeResourcesList } from './BikeResourcesList'; +export { BikeRentalList } from './BikeRentalList'; +export { InformationSection } from './InformationSection'; +export { Footer } from './Footer'; + +// Types +export type { + LocationProps, + ToggleSwitchProps, + BikeRoutesProps, + MapLayersProps, + AttractionsListProps, + BikeResourcesListProps, + BikeRentalListProps, + ExternalLinkProps, +} from './types'; diff --git a/src/components/sidebar/types.ts b/src/components/sidebar/types.ts new file mode 100644 index 0000000..dd35230 --- /dev/null +++ b/src/components/sidebar/types.ts @@ -0,0 +1,49 @@ +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export interface LocationProps { + name: string; + description: string; + icon: IconDefinition; + latitude?: number; + longitude?: number; + address?: string; +} + +export interface ToggleSwitchProps { + isActive: boolean; + color?: string; +} + +export interface BikeRoutesProps { + selectedRoute: string | null; + onRouteSelect: (routeId: string) => void; +} + +export interface MapLayersProps { + showAttractions: boolean; + showBikeResources: boolean; + showBikeRentals: boolean; + onToggleAttractions: () => void; + onToggleBikeResources: () => void; + onToggleBikeRentals: () => void; +} + +export interface AttractionsListProps { + show: boolean; + onCenterLocation: (location: LocationProps) => void; +} + +export interface BikeResourcesListProps { + show: boolean; + onCenterLocation: (location: LocationProps) => void; +} + +export interface BikeRentalListProps { + show: boolean; + onCenterLocation: (location: LocationProps) => void; +} + +export interface ExternalLinkProps { + href: string; + children: React.ReactNode; +} diff --git a/src/config/map.config.test.ts b/src/config/map.config.test.ts new file mode 100644 index 0000000..892fb45 --- /dev/null +++ b/src/config/map.config.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from 'vitest'; +import { mapConfig, getGBFSUrl } from './map.config'; + +describe('map.config', () => { + describe('mapConfig', () => { + it('should have valid mapbox configuration', () => { + expect(mapConfig.mapbox.accessToken).toBeDefined(); + expect(mapConfig.mapbox.accessToken).toMatch(/^pk\./); + expect(mapConfig.mapbox.styleUrl).toBeDefined(); + expect(mapConfig.mapbox.styleUrl).toMatch(/^mapbox:\/\/styles\//); + }); + + it('should have valid default view settings', () => { + expect(mapConfig.defaultView.center).toHaveLength(2); + expect(mapConfig.defaultView.center[0]).toBeGreaterThanOrEqual(-180); + expect(mapConfig.defaultView.center[0]).toBeLessThanOrEqual(180); + expect(mapConfig.defaultView.center[1]).toBeGreaterThanOrEqual(-90); + expect(mapConfig.defaultView.center[1]).toBeLessThanOrEqual(90); + expect(mapConfig.defaultView.zoom).toBeGreaterThan(0); + expect(typeof mapConfig.defaultView.pitch).toBe('number'); + expect(typeof mapConfig.defaultView.bearing).toBe('number'); + }); + + it('should have valid GBFS configuration', () => { + expect(mapConfig.gbfs.baseUrl).toBeDefined(); + expect(mapConfig.gbfs.baseUrl).toMatch(/^https?:\/\//); + expect(mapConfig.gbfs.endpoints.stationInformation).toBeDefined(); + expect(mapConfig.gbfs.endpoints.stationStatus).toBeDefined(); + }); + + it('should have valid region metadata', () => { + expect(mapConfig.region.name).toBeDefined(); + expect(mapConfig.region.displayName).toBeDefined(); + }); + + it('should have debug settings', () => { + expect(typeof mapConfig.debug.showLocationTracker).toBe('boolean'); + }); + }); + + describe('getGBFSUrl', () => { + it('should return full URL for stationInformation', () => { + const url = getGBFSUrl('stationInformation'); + expect(url).toBe( + `${mapConfig.gbfs.baseUrl}${mapConfig.gbfs.endpoints.stationInformation}`, + ); + expect(url).toContain('station_information'); + }); + + it('should return full URL for stationStatus', () => { + const url = getGBFSUrl('stationStatus'); + expect(url).toBe( + `${mapConfig.gbfs.baseUrl}${mapConfig.gbfs.endpoints.stationStatus}`, + ); + expect(url).toContain('station_status'); + }); + }); +}); diff --git a/src/config/map.config.ts b/src/config/map.config.ts new file mode 100644 index 0000000..706b51c --- /dev/null +++ b/src/config/map.config.ts @@ -0,0 +1,82 @@ +// Centralized map configuration +// This file contains all geo-specific settings to support multiple geographies in the future + +export interface MapConfig { + // Mapbox settings + mapbox: { + accessToken: string; + styleUrl: string; + }; + + // Default map view + defaultView: { + center: [number, number]; // [longitude, latitude] + zoom: number; + pitch: number; + bearing: number; + }; + + // GBFS (General Bikeshare Feed Specification) API settings + gbfs: { + baseUrl: string; + endpoints: { + stationInformation: string; + stationStatus: string; + }; + }; + + // Region metadata + region: { + name: string; + displayName: string; + }; + + // Debug/development settings + debug: { + showLocationTracker: boolean; + }; +} + +// Chattanooga configuration +const chattanoogaConfig: MapConfig = { + mapbox: { + accessToken: + 'pk.eyJ1Ijoic3d1bGxlciIsImEiOiJjbThyZTVuMzEwMTZwMmpvdTRzM3JpMGlhIn0.CF5lzLSkkfO-c0qt6a168A', + styleUrl: 'mapbox://styles/swuller/cm91zy289001p01qu4cdsdcgt', + }, + + defaultView: { + center: [-85.306739, 35.059623], // Outdoor Chattanooga + zoom: 14.89, + pitch: -22.4, + bearing: 11, + }, + + gbfs: { + baseUrl: 'https://chattanooga.publicbikesystem.net/customer/ube/gbfs/v1/en', + endpoints: { + stationInformation: '/station_information', + stationStatus: '/station_status', + }, + }, + + region: { + name: 'chattanooga', + displayName: 'Chattanooga', + }, + + debug: { + showLocationTracker: true, + }, +}; + +// Export the active configuration +// In the future, this could be selected based on environment variable or URL +export const mapConfig = chattanoogaConfig; + +// Helper to get full GBFS endpoint URLs +export function getGBFSUrl( + endpoint: keyof MapConfig['gbfs']['endpoints'], +): string { + return `${mapConfig.gbfs.baseUrl}${mapConfig.gbfs.endpoints[endpoint]}`; +} diff --git a/src/data/gbfs.ts b/src/data/gbfs.ts index 5b723fd..d2f8199 100644 --- a/src/data/gbfs.ts +++ b/src/data/gbfs.ts @@ -1,5 +1,6 @@ import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; import { faBicycle } from '@fortawesome/free-solid-svg-icons'; +import { getGBFSUrl } from '@/config/map.config'; // GBFS Station Information Types export interface GBFSStation { @@ -56,9 +57,7 @@ export function gbfsToBikeRentalLocation( // Fetch station information from GBFS API export async function fetchStationInformation(): Promise { - const response = await fetch( - 'https://chattanooga.publicbikesystem.net/customer/ube/gbfs/v1/en/station_information', - ); + const response = await fetch(getGBFSUrl('stationInformation')); if (!response.ok) { throw new Error('Failed to fetch station information'); } @@ -68,9 +67,7 @@ export async function fetchStationInformation(): Promise { // Fetch station status from GBFS API export async function fetchStationStatus(): Promise { - const response = await fetch( - 'https://chattanooga.publicbikesystem.net/customer/ube/gbfs/v1/en/station_status', - ); + const response = await fetch(getGBFSUrl('stationStatus')); if (!response.ok) { throw new Error('Failed to fetch station status'); } diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..0468976 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export { useToast } from './useToast'; +export { useLocationTracking } from './useLocationTracking'; +export { useMapResize } from './useMapResize'; diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts new file mode 100644 index 0000000..b25fd73 --- /dev/null +++ b/src/hooks/useLocationTracking.ts @@ -0,0 +1,153 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; +import type mapboxgl from 'mapbox-gl'; +import { createLocationMarker } from '@/components/MapMarkers'; + +interface UseLocationTrackingOptions { + map: React.MutableRefObject; +} + +interface UseLocationTrackingReturn { + isTracking: boolean; + toggleTracking: () => void; + initializeLocationMarker: () => void; + initializeGestureWatch: () => void; + cleanup: () => void; +} + +export function useLocationTracking({ + map, +}: UseLocationTrackingOptions): UseLocationTrackingReturn { + const locationMarker = useRef(null); + const watchId = useRef(null); + const locationWatch = useRef(undefined); + const [isTracking, setIsTracking] = useState(false); + + const initializeLocationMarker = useCallback(() => { + const gpsOptions = { + enableHighAccuracy: true, + maximumAge: 0, + timeout: 5000, + }; + + const id = navigator.geolocation.watchPosition( + (position) => { + if (!map.current) { + return; + } + + if (!locationMarker.current) { + locationMarker.current = createLocationMarker( + position.coords.longitude, + position.coords.latitude, + ); + locationMarker.current.addTo(map.current); + } else { + locationMarker.current.setLngLat({ + lng: position.coords.longitude, + lat: position.coords.latitude, + }); + } + }, + () => { + if (locationMarker.current) { + locationMarker.current.remove(); + locationMarker.current = null; + } + }, + gpsOptions, + ); + + watchId.current = id; + }, [map]); + + const initializeGestureWatch = useCallback(() => { + if (!map.current) { + return; + } + + const disableTracking = () => { + setIsTracking(false); + if (locationWatch.current) { + clearInterval(locationWatch.current); + locationWatch.current = undefined; + } + }; + + map.current.on('click', disableTracking); + map.current.on('touch', disableTracking); + map.current.on('touchend', disableTracking); + }, [map]); + + const setLocationWatch = useCallback( + (value: boolean) => { + setIsTracking(value); + + if (value) { + // When enabled: immediately center on current location + if (map.current && locationMarker.current) { + const lngLat = locationMarker.current.getLngLat(); + map.current.flyTo({ + center: [lngLat.lng, lngLat.lat], + essential: true, + duration: 1000, + }); + } + + // Then continuously track position + locationWatch.current = setInterval(() => { + if (!map.current || !locationMarker.current) { + return; + } + + const lngLat = locationMarker.current.getLngLat(); + map.current.flyTo({ + center: [lngLat.lng, lngLat.lat], + essential: true, + duration: 1000, + }); + }, 500); + } else { + // When disabled: stop tracking + if (locationWatch.current) { + clearInterval(locationWatch.current); + locationWatch.current = undefined; + } + } + }, + [map], + ); + + const toggleTracking = useCallback(() => { + setLocationWatch(!isTracking); + }, [isTracking, setLocationWatch]); + + const cleanup = useCallback(() => { + if (watchId.current !== null) { + navigator.geolocation.clearWatch(watchId.current); + watchId.current = null; + } + + if (locationWatch.current) { + clearInterval(locationWatch.current); + locationWatch.current = undefined; + } + + if (locationMarker.current) { + locationMarker.current.remove(); + locationMarker.current = null; + } + }, []); + + // Cleanup on unmount + useEffect(() => { + return cleanup; + }, [cleanup]); + + return { + isTracking, + toggleTracking, + initializeLocationMarker, + initializeGestureWatch, + cleanup, + }; +} diff --git a/src/hooks/useMapResize.ts b/src/hooks/useMapResize.ts new file mode 100644 index 0000000..b43a9d2 --- /dev/null +++ b/src/hooks/useMapResize.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import type mapboxgl from 'mapbox-gl'; + +interface UseMapResizeOptions { + map: React.MutableRefObject; +} + +export function useMapResize({ map }: UseMapResizeOptions) { + useEffect(() => { + const handleResize = () => { + if (map.current) { + map.current.resize(); + } + }; + + const handleSidebarToggle = () => { + // Delay to wait for sidebar transition + setTimeout(() => { + if (map.current) { + map.current.resize(); + } + }, 300); + }; + + window.addEventListener('resize', handleResize); + window.addEventListener('sidebar-toggle', handleSidebarToggle); + + return () => { + window.removeEventListener('resize', handleResize); + window.removeEventListener('sidebar-toggle', handleSidebarToggle); + }; + }, [map]); +} diff --git a/src/hooks/useToast.test.ts b/src/hooks/useToast.test.ts new file mode 100644 index 0000000..09b2116 --- /dev/null +++ b/src/hooks/useToast.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useToast } from './useToast'; + +describe('useToast', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should initialize with no message', () => { + const { result } = renderHook(() => useToast()); + + expect(result.current.message).toBeNull(); + expect(result.current.isFadingOut).toBe(false); + }); + + it('should show a toast message when showToast is called', () => { + const { result } = renderHook(() => useToast()); + + act(() => { + result.current.showToast('Hello World'); + }); + + expect(result.current.message).toBe('Hello World'); + expect(result.current.isFadingOut).toBe(false); + }); + + it('should start fading out before auto-dismiss', () => { + const { result } = renderHook(() => useToast(3000)); + + act(() => { + result.current.showToast('Test Message'); + }); + + expect(result.current.isFadingOut).toBe(false); + + // Advance time to just before fade-out starts (2700ms for 3000ms duration) + act(() => { + vi.advanceTimersByTime(2699); + }); + + expect(result.current.isFadingOut).toBe(false); + + // Advance past fade-out start + act(() => { + vi.advanceTimersByTime(2); + }); + + expect(result.current.isFadingOut).toBe(true); + expect(result.current.message).toBe('Test Message'); + }); + + it('should auto-dismiss toast after duration', () => { + const { result } = renderHook(() => useToast(3000)); + + act(() => { + result.current.showToast('Auto Dismiss'); + }); + + expect(result.current.message).toBe('Auto Dismiss'); + + // Advance past full duration + act(() => { + vi.advanceTimersByTime(3001); + }); + + expect(result.current.message).toBeNull(); + expect(result.current.isFadingOut).toBe(false); + }); + + it('should reset fade-out state when showing new toast', () => { + const { result } = renderHook(() => useToast(3000)); + + act(() => { + result.current.showToast('First Message'); + }); + + // Advance to fade-out state + act(() => { + vi.advanceTimersByTime(2700); + }); + + expect(result.current.isFadingOut).toBe(true); + + // Show new toast + act(() => { + result.current.showToast('Second Message'); + }); + + expect(result.current.message).toBe('Second Message'); + expect(result.current.isFadingOut).toBe(false); + }); + + it('should use custom duration', () => { + const { result } = renderHook(() => useToast(1000)); + + act(() => { + result.current.showToast('Quick Toast'); + }); + + // Should not be dismissed yet at 500ms + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current.message).toBe('Quick Toast'); + + // Should be dismissed after 1000ms + act(() => { + vi.advanceTimersByTime(501); + }); + + expect(result.current.message).toBeNull(); + }); +}); diff --git a/src/hooks/useToast.ts b/src/hooks/useToast.ts new file mode 100644 index 0000000..e5ce846 --- /dev/null +++ b/src/hooks/useToast.ts @@ -0,0 +1,39 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface UseToastReturn { + message: string | null; + isFadingOut: boolean; + showToast: (message: string) => void; +} + +export function useToast(duration = 3000): UseToastReturn { + const [message, setMessage] = useState(null); + const [isFadingOut, setIsFadingOut] = useState(false); + + const showToast = useCallback((newMessage: string) => { + setMessage(newMessage); + setIsFadingOut(false); + }, []); + + useEffect(() => { + if (message && !isFadingOut) { + // Start fade-out animation 300ms before removal + const fadeOutTimer = setTimeout(() => { + setIsFadingOut(true); + }, duration - 300); + + // Remove the toast after duration + const removeTimer = setTimeout(() => { + setMessage(null); + setIsFadingOut(false); + }, duration); + + return () => { + clearTimeout(fadeOutTimer); + clearTimeout(removeTimer); + }; + } + }, [message, isFadingOut, duration]); + + return { message, isFadingOut, showToast }; +} diff --git a/src/utils/utils.test.ts b/src/utils/map.test.ts similarity index 99% rename from src/utils/utils.test.ts rename to src/utils/map.test.ts index a505275..19a60c7 100644 --- a/src/utils/utils.test.ts +++ b/src/utils/map.test.ts @@ -6,7 +6,7 @@ import { calculateRouteBounds, findLocationInArray, findMarkerByCoordinates, -} from './utils'; +} from './map'; import type { BikeRoute } from '@/data/geo_data'; import type { IconDefinition } from '@fortawesome/free-solid-svg-icons'; import type mapboxgl from 'mapbox-gl'; diff --git a/src/utils/utils.ts b/src/utils/map.ts similarity index 100% rename from src/utils/utils.ts rename to src/utils/map.ts From 5c47bc68351cbb87f2e761d7b01a490fa26b06d8 Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Fri, 9 Jan 2026 16:20:57 -0500 Subject: [PATCH 3/6] Fix: sync sidebar selection when clicking routes on map Add event listener in MapLegend to handle route-select events dispatched from the map. Previously, clicking a route on the map would highlight it visually but not update the sidebar selection. Co-Authored-By: Claude Opus 4.5 --- src/components/MapLegend.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/components/MapLegend.tsx b/src/components/MapLegend.tsx index 5ab6e25..9102e0d 100644 --- a/src/components/MapLegend.tsx +++ b/src/components/MapLegend.tsx @@ -70,6 +70,21 @@ export function MapLegendProvider({ children }: { children: React.ReactNode }) { }; }, [isOpen, toggle]); + // Listen for route-select events from the map (when user clicks on a route in the map) + useEffect(() => { + const handleMapRouteSelect = (event: Event) => { + const customEvent = event as CustomEvent<{ routeId: string }>; + const { routeId } = customEvent.detail; + // Only update state, don't dispatch another event (map already handles the visual update) + setSelectedRoute(routeId); + }; + + window.addEventListener('route-select', handleMapRouteSelect); + return () => { + window.removeEventListener('route-select', handleMapRouteSelect); + }; + }, []); + // Function to handle route selection const handleRouteSelect = useCallback( (routeId: string) => { From 0f9caa994a354c953c7ac4461c420129c7fb2eba Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Fri, 9 Jan 2026 16:44:31 -0500 Subject: [PATCH 4/6] docs: update CLAUDE.md with project structure and testing notes - Add project structure overview with new directories - Document centralized config for multi-geography support - Expand event-driven communication with bidirectional sync details - Add testing section with Mapbox limitations and browser testing tips --- CLAUDE.md | 82 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 76 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a2e0cce..bf67adf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,32 @@ pnpm lint:fix # Auto-fix linting/formatting issues This is a Next.js 15 App Router application displaying an interactive Mapbox map of Chattanooga bike routes and resources. +### Project Structure + +``` +src/ +├── app/ # Next.js App Router pages +├── components/ +│ ├── Map.tsx # Main map orchestrator +│ ├── MapLegend.tsx # Sidebar container with state management +│ ├── MapMarkers.tsx # Marker factory and MarkerManager class +│ └── sidebar/ # Extracted sidebar components +│ ├── BikeRoutes.tsx, MapLayers.tsx, etc. +│ ├── types.ts # Shared interfaces +│ └── index.ts # Barrel export +├── config/ +│ └── map.config.ts # Centralized geo config (for multi-geography support) +├── data/ +│ ├── geo_data.ts # Static routes, attractions, bike shops +│ └── gbfs.ts # Live bike share API integration +├── hooks/ +│ ├── useToast.ts # Toast notification with auto-dismiss +│ ├── useMapResize.ts # Window/sidebar resize handling +│ └── useLocationTracking.ts +└── utils/ + └── map.ts # Geocoding, route opacity, bounds utilities +``` + ### Core Data Flow 1. **Page Entry** (`src/app/page.tsx`): Dynamically imports Map component with SSR disabled (Mapbox requires browser) @@ -25,14 +51,29 @@ This is a Next.js 15 App Router application displaying an interactive Mapbox map - `geo_data.ts`: Static data for bike routes, attractions, bike shops (BikeRoute, MapFeature, BikeResource interfaces) - `gbfs.ts`: Live bike share station data from Chattanooga GBFS API +### Configuration + +`src/config/map.config.ts` centralizes all geography-specific settings: +- Mapbox access token and style URL +- Default map view (center, zoom, pitch, bearing) +- GBFS API endpoints +- Region metadata + +This enables future multi-geography support by swapping config files. + ### Event-Driven Communication -The app uses custom DOM events for component communication: -- `route-select`: Highlights a bike route and zooms to its bounds -- `layer-toggle`: Shows/hides marker layers (attractions, bikeResources, bikeRentals) -- `center-location`: Pans map to a specific location -- `route-deselect`: Resets route opacity -- `sidebar-toggle`: Triggers map resize after sidebar animation +The app uses custom DOM events (`window.dispatchEvent`) for component communication: + +| Event | Dispatched By | Handled By | Purpose | +|-------|--------------|------------|---------| +| `route-select` | Sidebar, Map | Map, Sidebar | Bidirectional sync: highlights route on map AND in sidebar | +| `layer-toggle` | Sidebar | Map | Shows/hides marker layers (attractions, bikeResources, bikeRentals) | +| `center-location` | Sidebar | Map | Pans map to a specific location | +| `route-deselect` | Sidebar | Map | Resets all route opacities | +| `sidebar-toggle` | Sidebar | Map | Triggers map resize after sidebar animation | + +**Important**: `route-select` is bidirectional - both Map and MapLegend listen for it. When clicking a route on the map, the map dispatches the event and MapLegend updates its selection state. When clicking in the sidebar, MapLegend dispatches the event and Map handles the visual update. ### Marker System @@ -44,9 +85,38 @@ Routes are styled via Mapbox Studio (referenced by layer IDs like `riverwalk-loo ## Code Style +- Do not include "Co-Authored-By: Claude" in commit messages - Use `function` keyword for pure functions and components - Prefer interfaces over type aliases; avoid enums (use maps) - Use functional components; minimize `use client` - File order: exported component → subcomponents → helpers → static content → types - Use existing icon libraries (Font Awesome or lucide-react) - don't add new ones - Directories use lowercase-dash naming + +## Testing + +### Unit Tests + +Tests are in `*.test.ts` or `*.test.tsx` files adjacent to their source files. Run with `pnpm test:run`. + +Key test files: +- `src/utils/map.test.ts` - Utility function tests +- `src/config/map.config.test.ts` - Configuration tests +- `src/hooks/useToast.test.ts` - Hook tests +- `src/components/sidebar/BikeRoutes.test.tsx` - Component tests +- `src/data/gbfs.test.ts` - API integration tests + +### Mapbox Testing Limitations + +**Synthetic events don't trigger Mapbox layer clicks.** Mapbox's internal event system requires real user interactions to detect clicks on map layers. When testing: +- You cannot programmatically click on route lines using `MouseEvent` +- Use `window.dispatchEvent(new CustomEvent('route-select', { detail: { routeId } }))` to simulate what the map would do +- The Chrome DevTools MCP server can take screenshots but cannot trigger Mapbox layer events + +### Browser Testing + +Use Chrome DevTools MCP server for visual verification: +- Take screenshots to verify UI state +- Click on DOM elements (sidebar buttons work) +- Dispatch custom events to test event handlers +- Cannot test direct map layer interactions (requires manual testing) From df59a072f169282ca46a9cdf409f4502536eaed9 Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Fri, 9 Jan 2026 16:49:40 -0500 Subject: [PATCH 5/6] docs: add gh CLI instructions to CLAUDE.md --- CLAUDE.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bf67adf..0e1c986 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,6 +13,20 @@ pnpm lint # Run ESLint + Biome lint + Biome format checks pnpm lint:fix # Auto-fix linting/formatting issues ``` +## Git & GitHub + +Use `gh` CLI for GitHub operations: + +```bash +gh pr create --title "Title" --body "Description" # Create PR +gh pr view [number] # View PR details +gh pr edit [number] --body "New description" # Edit PR +gh pr merge [number] # Merge PR +gh pr list # List open PRs +gh issue list # List issues +gh issue view [number] # View issue details +``` + ## Architecture This is a Next.js 15 App Router application displaying an interactive Mapbox map of Chattanooga bike routes and resources. From 7ed7e06c6c56073d6b7c3e474e2d8f68a2f26385 Mon Sep 17 00:00:00 2001 From: Kyle Wiens Date: Fri, 9 Jan 2026 17:10:36 -0500 Subject: [PATCH 6/6] ci: use pnpm version from package.json packageManager --- .github/workflows/test.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b1fd80..6ae7842 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,8 +17,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.15.1 - name: Setup Node.js uses: actions/setup-node@v4 @@ -45,8 +43,6 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 - with: - version: 10.15.1 - name: Setup Node.js uses: actions/setup-node@v4