Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions .specify/specs/032-measure-tape/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# Implementation Plan: Map Measuring Tape

> **Spec ID:** 032-measure-tape
> **Status:** Planning
> **Last Updated:** 2026-05-31
> **Estimated Effort:** S

## Summary

Add a `measureMode` boolean to `Map`, a ruler toggle button in the existing
`ZoomLocateControl` cluster, and a new `MeasureTape` `useMap()` child component that
manages two draggable Leaflet markers + a polyline + a distance tooltip while active.
Frontend-only, no backend or DB changes.

---

## Architecture

### Component Diagram

```
┌─────────────────────── Map.jsx ───────────────────────┐
│ state: measureMode (bool) │
│ │
│ <MapContainer> │
│ ┌──────────────────────────────────────────────┐ │
│ │ ZoomLocateControl │ │
│ │ + / − / locate / satellite / [MEASURE 📏] ──┼──▶ toggles measureMode
│ └──────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────┐ │
│ │ MeasureTape (active only) │ │
│ │ markerA ●╌╌╌polyline╌╌╌● markerB │ │
│ │ tooltip: "1.24 mi (2.0 km)" │ │
│ └──────────────────────────────────────────────┘ │
│ </MapContainer> │
└────────────────────────────────────────────────────────┘
```

### Data Flow

1. User clicks the ruler button → `ZoomLocateControl` calls `onToggleMeasure()` → `Map` flips `measureMode`.
2. `measureMode` true → `<MeasureTape active />` mounts; on mount it computes two default
endpoints straddling the center of the current viewport via
`map.containerPointToLatLng()` and adds markers + polyline + tooltip to the map.
3. Dragging an endpoint fires `drag` → update the polyline latlngs and recompute the
label with `map.distance(a, b)`.
4. `measureMode` false (or unmount) → remove markers, polyline, tooltip.

---

## Technology Choices

| Component | Technology | Rationale |
|-----------|------------|-----------|
| Endpoints | `L.marker({ draggable: true })` with a `divIcon` handle | `L.marker` supports native dragging; `CircleMarker` does not |
| Line | `L.polyline` | Lightweight, redraws on drag |
| Label | `L.tooltip` bound to the polyline midpoint (permanent) | Stays attached, no extra DOM plumbing |
| Distance | `map.distance(a, b)` (geodesic, meters) | Accurate at any latitude; matches existing river-gauge code style |
| Toggle | Extra `<a>` in the existing `ZoomLocateControl` `L.Control` | Reuses the established control pattern and styling |

---

## Implementation Steps

### Phase 1: Toggle plumbing

- [ ] Add `measureMode` state + `onToggleMeasure` to `Map`.
- [ ] Add a ruler `<a class="zoom-locate-btn measure-button">` to `ZoomLocateControl`, wired to `onToggleMeasure`; reflect active state with an `active` class.
- [ ] Pass `useMeasure`/`onToggleMeasure` props into `ZoomLocateControl` (mirrors `useSatellite`/`onSatelliteToggle`).

### Phase 2: MeasureTape component

- [ ] New `MeasureTape({ active })` `useMap()` child.
- [ ] On activate: compute default A/B straddling viewport center (e.g. container points at 40%×50% and 60%×50%), add two draggable divIcon markers, a polyline, and a permanent midpoint tooltip.
- [ ] `drag` handlers update polyline + tooltip position + label live.
- [ ] `formatDistance(meters)` → imperial primary (ft `< 0.1 mi`, else mi 2dp) + metric secondary (m `< 1 km`, else km 2dp).
- [ ] Cleanup on deactivate/unmount removes all layers; re-activate resets to default position.

### Phase 3: Styling & polish

- [ ] CSS for `.measure-button` (matches sibling control buttons) + active state.
- [ ] CSS for `.measure-handle` divIcon (≥24px, grabbable) and `.measure-tooltip` label.
- [ ] `L.DomEvent.disableClickPropagation` so dragging doesn't pan/select.

---

## File Changes

### New Files

| File | Purpose |
|------|---------|
| (none — `MeasureTape` lives in `Map.jsx` alongside the other `useMap` children) | Keeps the map components co-located, as `ZoomLocateControl` already is |

### Modified Files

| File | Changes |
|------|---------|
| `frontend/src/components/Map.jsx` | Add `measureMode` state; add `MeasureTape` component; add ruler button + props to `ZoomLocateControl`; render `<MeasureTape>` inside `MapContainer` |
| `frontend/src/App.css` | `.measure-button`, `.measure-handle`, `.measure-tooltip` styles (near the existing `.zoom-locate-btn` rules) |

---

## Database Migrations

None.

---

## API Implementation

None.

---

## Testing Strategy

### Manual Testing

1. Click the ruler button → tape appears in the bottom-right with a distance label.
2. Drag endpoint A onto one POI and B onto another → label updates live and reads a plausible distance.
3. Zoom in/out and pan → endpoints stay glued to their map locations; the distance number stays stable until an endpoint is moved.
4. Verify dragging an endpoint does NOT pan the map.
5. Toggle the button off → tape fully disappears; toggle on → resets to bottom-right.
6. Touch test (or narrow viewport) → handles are grabbable.

### Automated

- Existing Playwright smoke suite must still pass (`./run.sh test`, run by `/deploy`). No new e2e required for v1; the tool is additive and inactive by default.

---

## Rollback Plan

1. Frontend-only and inactive by default — revert the `Map.jsx`/CSS changes.
2. No data migration to unwind.

---

## Risks and Mitigations

| Risk | Impact | Mitigation |
|------|--------|------------|
| Endpoint drag pans the map | Med | `disableClickPropagation` + marker `draggable` handles its own events |
| Tooltip/markers leak on toggle | Low | Explicit cleanup in `useEffect` return; keyed on `active` |
| Distance label overlaps controls | Low | Default endpoints centered, clear of the top-left controls |

---

## Changelog

| Date | Changes |
|------|---------|
| 2026-05-31 | Initial plan |
145 changes: 145 additions & 0 deletions .specify/specs/032-measure-tape/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Specification: Map Measuring Tape

> **Spec ID:** 032-measure-tape
> **Status:** Draft
> **Version:** 0.1.0
> **Author:** Scott McCarty
> **Date:** 2026-05-31

## Overview

Visitors want to know how far apart two places are on the map — trailheads, a
parking lot and a waterfall, two POIs they're deciding between. Today there is no
way to do that. This feature adds a **two-point measuring tape**: a toggleable map
tool that drops two draggable endpoints (A and B), draws a line between them, and
reports the real-world distance live as you drag either end or zoom/pan the map.

Resolves [#452](https://github.com/crunchtools/rotv/issues/452) ("Map Scale Key").
The issue asked for a zoom-aware scale key so users could gauge how far apart POIs
are; a draggable measuring tape solves that problem statement more directly than a
fixed scale bar.

---

## User Stories

### Distance Measurement

**US-001: Measure between two points**
> As a visitor, I want to drop two points on the map and read the distance between
> them so that I can tell how far apart trailheads, POIs, or features are.

Acceptance Criteria:
- [ ] A measure toggle button is available in the map control cluster (top-left, with zoom/locate/satellite).
- [ ] Activating it shows two draggable endpoint handles (A and B) connected by a line.
- [ ] A label shows the geodesic distance between A and B, in imperial primary (ft / mi) with metric secondary (m / km).
- [ ] Endpoints first appear near the center of the current viewport so they're immediately visible and easy to grab.

**US-002: Drag endpoints to measure anything**
> As a visitor, I want to drag each endpoint independently so that I can line them up
> on the two things I actually want to measure.

Acceptance Criteria:
- [ ] Both endpoints are independently draggable.
- [ ] The connecting line and the distance label update live during the drag.
- [ ] Endpoints are large enough to grab on a touchscreen.

**US-003: Stays accurate through zoom and pan**
> As a visitor, I want the distance to stay correct when I zoom or pan so that I trust
> the number.

Acceptance Criteria:
- [ ] Endpoints are anchored to geographic coordinates (lat/lng), not screen pixels — they stay on their map locations through zoom/pan.
- [ ] The reported distance is geodesic (`map.distance`) and does not change on zoom unless an endpoint is moved.

**US-004: Turn it off / get out of the way**
> As a visitor, I want to dismiss the tape when I'm done so that it stops cluttering
> the map.

Acceptance Criteria:
- [ ] Toggling the button off removes both endpoints, the line, and the label.
- [ ] The toggle button shows an active state while the tape is on.
- [ ] Turning the tape off and on again resets endpoints to the default centered position.

---

## Data Model

No database changes. This is a client-only, ephemeral UI tool — measurements are not
persisted.

---

## API Endpoints

None. No backend changes.

---

## UI/UX Requirements

### New Components

- `MeasureTape` — a `useMap()` child of `MapContainer` that, while active, manages two
draggable Leaflet markers, a connecting polyline, and a distance tooltip. Renders
nothing when inactive.

### New Control

- A ruler-icon toggle button appended to the existing `ZoomLocateControl` button
cluster (top-left), driven by a `measureMode` boolean lifted into `Map`.

### Wireframe

```
map controls (top-left) measuring tape (starts centered)
┌───┐
│ + │
│ − │ A ●╌╌╌╌╌╌╌╌╌● B
│ ◎ │ ← locate ┌─────────────┐
│ ▦ │ ← satellite │ 1.24 mi │
│ 📏│ ← measure (NEW) │ (2.0 km) │
└───┘ appended to cluster └─────────────┘
```

### Units

- Imperial primary (US national-park audience): `< 0.1 mi` shown in feet, otherwise miles (2 decimals).
- Metric secondary in parentheses: `< 1 km` shown in meters, otherwise km (2 decimals).

---

## Non-Functional Requirements

**NFR-001: No regression to existing map interaction**
- The tape must not block map clicks, POI selection, or other controls when inactive.
- Dragging an endpoint must not pan the map.

**NFR-002: Accessibility & touch**
- Toggle button has `role="button"`, `aria-label`, and an `aria-pressed`/active state.
- Endpoint handles are at least 24×24px hit targets.

**NFR-003: Code quality**
- Passes the Gourmand gate (no `//` line comments except JSDoc; no single-use helpers).

---

## Dependencies

- Depends on: existing `MapContainer` / `ZoomLocateControl` infrastructure in `Map.jsx`.
- Blocks: none.

---

## Open Questions

1. Should the tape support more than two points (multi-segment path)? — Out of scope for v1; two points only.
2. Should measurements persist across reloads? — No; ephemeral by design.

---

## Changelog

| Version | Date | Changes |
|---------|------|---------|
| 0.1.0 | 2026-05-31 | Initial draft |
5 changes: 3 additions & 2 deletions backend/tests/ui.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,14 +233,15 @@ describe('UI Integration Tests', () => {

// Get all buttons in order
const buttons = await page.locator('.zoom-locate-control .zoom-locate-btn').all();
expect(buttons.length).toBe(4);
expect(buttons.length).toBe(5);

// Verify order: zoom in, zoom out, locate, satellite
// Verify order: zoom in, zoom out, locate, satellite, measure
const classNames = await Promise.all(buttons.map(btn => btn.getAttribute('class')));
expect(classNames[0]).toContain('zoom-in-btn');
expect(classNames[1]).toContain('zoom-out-btn');
expect(classNames[2]).toContain('locate-button');
expect(classNames[3]).toContain('satellite-toggle-button');
expect(classNames[4]).toContain('measure-button');
}, 30000);

it('should position map controls below header (not off-screen)', async () => {
Expand Down
71 changes: 71 additions & 0 deletions frontend/src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,77 @@ body {
height: 16px;
}

/* Measure-distance toggle button */
.measure-button:hover {
color: #2d5016 !important;
}

.measure-button.active {
color: #2d5016 !important;
background: #eaf2e1 !important;
}

.measure-button svg {
width: 16px;
height: 16px;
}

/* Measuring-tape endpoint handles (A / B) */
.measure-handle {
display: flex;
align-items: center;
justify-content: center;
cursor: grab;
}

.measure-handle:active {
cursor: grabbing;
}

.measure-handle-dot {
position: absolute;
width: 28px;
height: 28px;
border-radius: 50%;
background: rgba(45, 80, 22, 0.25);
border: 2px solid #2d5016;
box-sizing: border-box;
}

.measure-handle-letter {
position: relative;
font-size: 12px;
font-weight: 700;
color: #2d5016;
line-height: 1;
}

/* Distance label riding the midpoint of the tape */
.measure-tooltip.leaflet-tooltip {
width: auto;
min-width: 0;
max-width: none;
background: #2d5016;
color: #fff;
border: none;
border-radius: 6px;
padding: 4px 8px;
font-size: 13px;
font-weight: 700;
white-space: nowrap;
text-align: center;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}

.measure-tooltip.leaflet-tooltip::before {
display: none;
}

.measure-tooltip-secondary {
font-weight: 400;
opacity: 0.85;
}

/* User location marker pulse effect */
.user-location-pulse {
animation: user-pulse 2s infinite;
Expand Down
Loading
Loading