# Station Map (Leaflet + OSM) Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add an interactive Leaflet/OSM map to the station search page that plots colour-coded markers for each result and centres on the searched location. **Architecture:** A Leaflet map is managed by an Alpine.js component (`stationMap`) registered via `alpine:init`. The component receives the Livewire `results` array via `@entangle` and re-plots markers reactively whenever the data changes. The API meta response is extended to include the resolved search `lat`/`lng` so the map can centre precisely on the search point. **Tech Stack:** Leaflet 1.x (npm), Alpine.js (bundled with Livewire 4), Livewire `@entangle`, Tailwind CSS v4. --- ## File Map | Action | File | Responsibility | |--------|------|----------------| | Modify | `package.json` | Add `leaflet` npm dependency | | Modify | `resources/css/app.css` | Import Leaflet CSS | | Create | `resources/js/maps/station-map.js` | Alpine component: map init, marker plotting, colour logic, popups | | Modify | `resources/js/app.js` | Register `stationMap` Alpine component on `alpine:init` | | Modify | `app/Http/Controllers/Api/StationController.php` | Add `lat`/`lng` to `meta` response | | Modify | `resources/views/livewire/public/station-search.blade.php` | Add map `
` with `x-data` above the results list | | Modify | `tests/Feature/Api/StationControllerTest.php` | Assert `meta.lat` and `meta.lng` are present | | Modify | `tests/Feature/Livewire/StationSearchTest.php` | Add `lat`/`lng` to the faked `meta` fixture | --- ## Task 1: Install Leaflet and import its CSS **Files:** - Modify: `package.json` - Modify: `resources/css/app.css` - [ ] **Step 1: Install leaflet via npm** ```bash cd /Users/bitstream/code/fuel-price && npm install leaflet ``` Expected: `leaflet` appears in `node_modules/leaflet` and `package.json` `dependencies`. - [ ] **Step 2: Import Leaflet CSS in app.css** Add this line at the top of `resources/css/app.css` (before the Tailwind import): ```css @import 'leaflet/dist/leaflet.css'; @import 'tailwindcss'; @import '../../vendor/livewire/flux/dist/flux.css'; /* … rest unchanged … */ ``` - [ ] **Step 3: Verify build succeeds** ```bash cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5 ``` Expected: Build completes without errors. - [ ] **Step 4: Commit** ```bash git add package.json package-lock.json resources/css/app.css git commit -m "feat: install leaflet and import CSS" ``` --- ## Task 2: Add lat/lng to API meta response + test **Files:** - Modify: `app/Http/Controllers/Api/StationController.php:87-98` - Modify: `tests/Feature/Api/StationControllerTest.php` - [ ] **Step 1: Write a failing test** Add this test to `tests/Feature/Api/StationControllerTest.php`: ```php it('includes resolved lat and lng in meta', function () { $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); StationPriceCurrent::factory()->create([ 'station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500, ]); $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=diesel&radius=10') ->assertOk() ->assertJsonPath('meta.lat', 52.555064) ->assertJsonPath('meta.lng', -0.256119); }); ``` - [ ] **Step 2: Run test to confirm it fails** ```bash cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -20 ``` Expected: FAIL — `meta.lat` not found. - [ ] **Step 3: Add lat/lng to the meta array in the controller** In `app/Http/Controllers/Api/StationController.php`, change the `return response()->json([...])` block (around line 87) from: ```php return response()->json([ 'data' => StationResource::collection($stations), 'meta' => [ 'count' => $stations->count(), 'fuel_type' => $fuelType->value, 'radius_km' => $radius, 'lowest_pence' => $prices->min(), 'highest_pence' => $prices->max(), 'cheapest_price_pence' => $prices->min(), 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, ], ]); ``` to: ```php return response()->json([ 'data' => StationResource::collection($stations), 'meta' => [ 'count' => $stations->count(), 'fuel_type' => $fuelType->value, 'radius_km' => $radius, 'lat' => $lat, 'lng' => $lng, 'lowest_pence' => $prices->min(), 'highest_pence' => $prices->max(), 'cheapest_price_pence' => $prices->min(), 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, ], ]); ``` - [ ] **Step 4: Run tests to confirm they pass** ```bash cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php --timeout=10 2>&1 | tail -10 ``` Expected: All tests PASS. - [ ] **Step 5: Run pint** ```bash cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent ``` - [ ] **Step 6: Commit** ```bash git add app/Http/Controllers/Api/StationController.php tests/Feature/Api/StationControllerTest.php git commit -m "feat: include search lat/lng in station API meta response" ``` --- ## Task 3: Create the station-map Alpine component **Files:** - Create: `resources/js/maps/station-map.js` - [ ] **Step 1: Create the directory and file** Create `resources/js/maps/station-map.js` with this content: ```js import L from 'leaflet'; // Fix Leaflet's broken default icon paths when bundled with Vite import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; import markerIcon from 'leaflet/dist/images/marker-icon.png'; import markerShadow from 'leaflet/dist/images/marker-shadow.png'; delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow, }); const CLASSIFICATION_COLOURS = { current: '#22c55e', // green-500 — price updated within 24h recent: '#64748b', // slate-500 — 24–48h stale: '#f59e0b', // amber-500 — 48–120h outdated: '#ef4444', // red-500 — 120h+ }; const UK_CENTRE = [54.0, -2.0]; const UK_ZOOM = 6; export function stationMap(results, meta) { return { results, meta, _map: null, _markers: [], init() { this._map = L.map(this.$el, { zoomControl: true }).setView(UK_CENTRE, UK_ZOOM); L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(this._map); // Plot immediately if results are already available (e.g. after page reload) if (this.results && this.results.length > 0) { this._plotMarkers(); } this.$watch('results', () => this._plotMarkers()); }, _clearMarkers() { this._markers.forEach((m) => m.remove()); this._markers = []; }, _plotMarkers() { this._clearMarkers(); if (!this.results || this.results.length === 0) { return; } const bounds = []; this.results.forEach((station) => { const colour = CLASSIFICATION_COLOURS[station.price_classification] ?? '#64748b'; const miles = (station.distance_km * 0.621371).toFixed(1); const supermarketTag = station.is_supermarket ? 'Supermarket' : ''; const popup = `
${station.name}${supermarketTag}
${Number(station.price).toFixed(1)}p
${miles} miles away
${station.address}, ${station.postcode}
`; const marker = L.circleMarker([station.lat, station.lng], { radius: 9, fillColor: colour, color: '#ffffff', weight: 2, opacity: 1, fillOpacity: 0.85, }).bindPopup(popup); marker.addTo(this._map); this._markers.push(marker); bounds.push([station.lat, station.lng]); }); // Centre on search point from meta if available, else fit station bounds if (this.meta && this.meta.lat && this.meta.lng) { this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); } else { this._map.fitBounds(bounds, { padding: [40, 40], maxZoom: 14 }); } }, }; } ``` - [ ] **Step 2: Verify no syntax errors** ```bash cd /Users/bitstream/code/fuel-price && node --input-type=module < resources/js/maps/station-map.js 2>&1 | head -5 ``` Expected: Either no output (success) or only an import error about Leaflet not being in Node context — that's fine; the goal is no syntax errors. Actually run the build instead: ```bash cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10 ``` Expected: Build succeeds (no parse/import errors). - [ ] **Step 3: Commit** ```bash git add resources/js/maps/station-map.js git commit -m "feat: create station-map Alpine/Leaflet component" ``` --- ## Task 4: Register the Alpine component in app.js **Files:** - Modify: `resources/js/app.js` - [ ] **Step 1: Update app.js** Replace the entire content of `resources/js/app.js` with: ```js import { stationMap } from './maps/station-map.js'; document.addEventListener('alpine:init', () => { Alpine.data('stationMap', stationMap); }); ``` - [ ] **Step 2: Verify build** ```bash cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -10 ``` Expected: Build succeeds. - [ ] **Step 3: Commit** ```bash git add resources/js/app.js git commit -m "feat: register stationMap Alpine component" ``` --- ## Task 5: Add map to the station-search Blade template **Files:** - Modify: `resources/views/livewire/public/station-search.blade.php` - [ ] **Step 1: Add map legend and map div inside the results section** In `resources/views/livewire/public/station-search.blade.php`, locate the `@if (! empty($results))` block (around line 71). Replace the existing `
` results list section with a version that includes the map above it: Replace this block (starting at line 71): ```blade @if (! empty($results))

{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p

``` with: ```blade @if (! empty($results))

{{ $meta['count'] }} {{ str('station')->plural($meta['count']) }} found · Cheapest: {{ number_format($meta['lowest_pence'] / 100, 1) }}p · Average: {{ number_format($meta['avg_pence'] / 100, 1) }}p

{{-- Map --}}
{{-- Legend --}}
Current (<24h) Recent (24–48h) Stale (2–5 days) Outdated (5+ days)
``` - [ ] **Step 2: Verify build** ```bash cd /Users/bitstream/code/fuel-price && npm run build 2>&1 | tail -5 ``` Expected: Build succeeds. - [ ] **Step 3: Commit** ```bash git add resources/views/livewire/public/station-search.blade.php git commit -m "feat: add Leaflet map and colour legend to station search results" ``` --- ## Task 6: Update Livewire component test meta fixture **Files:** - Modify: `tests/Feature/Livewire/StationSearchTest.php` The existing tests fake the API response with a `meta` array. Now that the real API returns `lat`/`lng`, the fakes should match to prevent subtle mismatches in future tests. - [ ] **Step 1: Update the faked meta in `populates results and meta on successful search`** In `tests/Feature/Livewire/StationSearchTest.php`, find the `'meta'` array in the `Http::fake` response (around line 55) and add `lat` and `lng`: Change: ```php 'meta' => [ 'count' => 1, 'fuel_type' => 'e10', 'radius_km' => 8.05, 'lowest_pence' => 14390, 'highest_pence' => 14390, 'cheapest_price_pence' => 14390, 'avg_pence' => 14390.0, ], ``` to: ```php 'meta' => [ 'count' => 1, 'fuel_type' => 'e10', 'radius_km' => 8.05, 'lat' => 51.5010, 'lng' => -0.1415, 'lowest_pence' => 14390, 'highest_pence' => 14390, 'cheapest_price_pence' => 14390, 'avg_pence' => 14390.0, ], ``` - [ ] **Step 2: Run full Livewire test file** ```bash cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -10 ``` Expected: All tests PASS. - [ ] **Step 3: Run pint** ```bash cd /Users/bitstream/code/fuel-price && vendor/bin/pint --dirty --format agent ``` - [ ] **Step 4: Run all modified test files one final time** ```bash cd /Users/bitstream/code/fuel-price && php artisan test --compact tests/Feature/Api/StationControllerTest.php tests/Feature/Livewire/StationSearchTest.php --timeout=10 2>&1 | tail -15 ``` Expected: All tests PASS. - [ ] **Step 5: Commit** ```bash git add tests/Feature/Livewire/StationSearchTest.php git commit -m "test: add lat/lng to faked meta fixture in StationSearchTest" ``` --- ## Self-Review **Spec coverage:** | Requirement | Task | |---|---| | Map centred on user's location | Task 2 adds lat/lng to meta; Task 3 uses it for `fitBounds` | | Plots station markers with price info in popup | Task 3 — `_plotMarkers()` with `bindPopup` | | Colour-codes markers by price classification | Task 3 — `CLASSIFICATION_COLOURS` map; Task 5 — legend | | Leaflet + OSM (no API costs) | Task 1 — OSM tile layer, no API key needed | **Placeholder scan:** No TBD or TODO in plan tasks. All code blocks are complete. **Type consistency:** - `stationMap(results, meta)` — factory signature matches `x-data="stationMap(@entangle('results'), @entangle('meta'))"` in Task 5. - `_plotMarkers()`, `_clearMarkers()` — consistent across Task 3. - `CLASSIFICATION_COLOURS` keyed on `current/recent/stale/outdated` — matches `PriceClassification` enum values and `StationResource` output.