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
16 changes: 16 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,22 @@ npm test # Vitest
- Pages under `src/pages/`; shared components under `src/components/`.
- API config: `config.ts` + remote `config.json`; `MESHFLOW_API_URL` for backend base URL.

### Component and screen documentation

When you **create** a non-trivial component or page screen, or **meaningfully change** one you touch, add or update a sibling markdown file next to the main `.tsx` (same basename, e.g. `HeardPathMap.tsx` → `HeardPathMap.md`).

Do **not** backfill docs for the whole codebase in one go; only document what you are working on so behaviour stays accurate.

Each doc should explain, in plain language:

- **Purpose** — what the UI is for and when to use it vs similar components (e.g. `HeardPathMap` vs `HeardPathGeoMap`).
- **Props / inputs** — main types and what drives rendering.
- **Behaviour** — what is drawn or shown, empty states, warnings, and non-obvious rules (maps, dialogs, protocol splits).
- **Wiring** — where it is used in the app and which adapters/hooks feed it.
- **Related** — links to paired components, feature docs under `docs/`, or API contracts when relevant.

Examples: [`src/components/messages/HeardPathMap.md`](src/components/messages/HeardPathMap.md), [`src/components/messages/HeardPathGeoMap.md`](src/components/messages/HeardPathGeoMap.md).

## Configuration

- Default config in `config.ts`
Expand Down
4 changes: 2 additions & 2 deletions docs/meshcore/heard-path-dialog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ Opened from **N heard** on a MeshCore text message in message history.

### Layout

1. **Geo map** — Leaflet markers for sender (when position known) and feeders with coordinates. No hop polylines on the map.
2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. Hops use dashed monospace badges (`unknown` per API v1); they are **not** placed at geographic coordinates.
1. **Geo map** — [`HeardPathGeoMap`](../../src/components/messages/HeardPathGeoMap.tsx) ([docs](../../src/components/messages/HeardPathGeoMap.md)): Leaflet markers for sender (when position known) and feeders with coordinates, plus optional **partial hop polylines** when API resolves hop positions. Ambiguous hops are list-only. Meshtastic uses [`HeardPathMap`](../../src/components/messages/HeardPathMap.tsx) ([docs](../../src/components/messages/HeardPathMap.md)) for full sender→feeder paths.
2. **Paths by feeder** — One schematic row per observation: sender → hash hops → feeder. A single `mc_sender_candidate` is shown as the sender (with node link) even when the node has no map position; `HopPositionIcon` reflects position only. Hops use dashed monospace badges when unresolved; they are **not** placed at geographic coordinates unless the API supplies hop positions.
3. **Feeder list** — Each observer with RSSI/SNR and a **Path (this feeder)** column showing that observation’s hop chain.

Each feeder can report a **different** `path_hashes` / `resolved_path` for the same message.
Expand Down
50 changes: 50 additions & 0 deletions src/components/messages/HeardPathGeoMap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# HeardPathGeoMap

Leaflet map for the **message heard** dialog that shows **only geographic anchors** (sender and feeders). It does **not** draw hop polylines, path segments, or hash labels on the map.

Use this when hop evidence is hash-based or schematic and should not be interpreted as real-world RF geometry on the map—typical for **MeshCore** heard dialogs today.

## When to use

| Use `HeardPathGeoMap` | Use `HeardPathMap` instead |
| ------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| MeshCore heard dialog top map (markers for “who” heard the message and where they sit) | Meshtastic heard dialog, or any case where you want sender → hop waypoints → feeder **lines** on the map |
| You will show the full hop chain elsewhere (e.g. `MeshCoreHeardPathsPanel`, `PathHopChain`) | `path_known` and/or positioned intermediate hops should appear as polylines |

These components are **not** drop-in replacements: props and rendering models differ (see below).

## Props

| Prop | Type | Role |
| ------------ | ---------------------------- | ----------------------------------------------------------------------------------- |
| `sender` | `HeardPathGeoAnchor \| null` | Sender marker when `position` is known |
| `feeders` | `HeardPathGeoAnchor[]` | One marker per feeder/feeder with a known position |
| `pathLegs` | `HeardPathLeg[]` (optional) | Per-feeder polylines and intermediate hop markers when positions are known |
| `senderName` | `string \| null` | Label for the amber banner when sender position is missing (e.g. `mc_sender_label`) |

`HeardPathGeoAnchor`: `{ label, position, color? }`. Feeder `color` defaults from `HEARD_PATH_LEG_COLORS` by index.

## What it draws

1. **Sender** (green, `HEARD_PATH_SENDER_COLOR`) — if `sender` is non-null.
2. **Feeder markers** — one per entry in `feeders`, with per-leg colour when `color` is set.
3. **Optional path overlay** — when `pathLegs` is set, draws partial polylines and positioned hop markers via [`heard-path-map-layers.ts`](./heard-path-map-layers.ts). Ambiguous hops are omitted from `pathLegs`. Without `pathLegs`, no path geometry is drawn.

Map bounds fit all markers (padding, max zoom 15). Default centre before fit: Glasgow area (`55.8642, -4.2518`).

## Empty and warning states

- **Empty** (`!sender && feeders.length === 0`): placeholder text — “No map — sender and feeder positions unknown” (`data-testid="heard-path-geo-map-empty"`).
- **Sender missing, feeders present**: amber overlay — sender position unknown; feeders shown; **hop paths are schematic below** (paths belong in `MeshCoreHeardPathsPanel` / list, not on this map).

## Wiring in the app

[`MessageItem.tsx`](./MessageItem.tsx) → `MeshCoreHeardDialogBody` builds `geoSender` and `geoFeeders` from `meshCoreHeardLegs(message)` (feeders with `receiverPosition` only) and renders `HeardPathGeoMap`.

Data still comes from message `heard[]` / API; this component only consumes **positions**, not hop hashes.

## Related

- [`HeardPathMap.md`](./HeardPathMap.md) — path polylines and hop geometry
- [`heard-path-map-adapters.ts`](./heard-path-map-adapters.ts) — `meshCoreHeardLegs`, Meshtastic vs MC adapters
- [`docs/meshcore/heard-path-dialog.md`](../../../docs/meshcore/heard-path-dialog.md) — full dialog layout
24 changes: 17 additions & 7 deletions src/components/messages/HeardPathGeoMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import { useMapTileUrl } from '@/hooks/useMapTileUrl';
import { createNodeIcon } from '@/components/nodes/map-utils';
import type { LatLng } from '@/lib/map-path-segments';
import type { HeardPathLeg } from './HeardPathMap';
import { drawHeardPathPolylinesOnly, hasDrawablePathOnMap, toLatLng } from './heard-path-map-layers';
import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
Expand All @@ -19,20 +21,20 @@
export type HeardPathGeoMapProps = {
sender: HeardPathGeoAnchor | null;
feeders: HeardPathGeoAnchor[];
/** Optional per-feeder path geometry (MeshCore); anchor markers remain on this map. */
pathLegs?: HeardPathLeg[];
senderName?: string | null;
};

function toLatLng(pos: MapPosition): LatLng {
return [pos.latitude, pos.longitude];
}

export function HeardPathGeoMap({ sender, feeders, senderName }: HeardPathGeoMapProps) {
export function HeardPathGeoMap({ sender, feeders, pathLegs = [], senderName }: HeardPathGeoMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
const tileLayerRef = useRef<L.TileLayer | null>(null);
const layersRef = useRef<L.Layer[]>([]);
const { url: tileUrl, attribution } = useMapTileUrl();

const senderPos = sender ? toLatLng(sender.position) : null;

useEffect(() => {
if (mapRef.current && !mapInstanceRef.current) {
const map = L.map(mapRef.current).setView(DEFAULT_CENTER, 13);
Expand All @@ -52,7 +54,7 @@
style.remove();
};
}
}, []);

Check warning on line 57 in src/components/messages/HeardPathGeoMap.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook useEffect has missing dependencies: 'attribution' and 'tileUrl'. Either include them or remove the dependency array

useEffect(() => {
const map = mapInstanceRef.current;
Expand Down Expand Up @@ -95,6 +97,10 @@
hasMarker = true;
});

if (pathLegs.length > 0) {
drawHeardPathPolylinesOnly(map, layersRef.current, bounds, senderPos, pathLegs);
}

if (!hasMarker) return;

map.invalidateSize();
Expand All @@ -109,7 +115,7 @@
}
}, 150);
return () => clearTimeout(t);
}, [sender, feeders]);
}, [sender, feeders, pathLegs, senderPos]);

if (!sender && feeders.length === 0) {
return (
Expand All @@ -123,6 +129,7 @@
}

const showSenderWarning = !sender;
const hasPartialPaths = !sender && hasDrawablePathOnMap(null, pathLegs);
const warningLabel = senderName?.trim() || sender?.label;

return (
Expand All @@ -134,7 +141,10 @@
role="status"
>
Sender position unknown
{warningLabel ? ` (${warningLabel})` : ''} — feeders shown; hop paths are schematic below.
{warningLabel ? ` (${warningLabel})` : ''} —
{hasPartialPaths
? ' paths use known hop and feeder positions only; full chain below.'
: ' feeders shown; hop paths are schematic below.'}
</div>
)}
</div>
Expand Down
65 changes: 65 additions & 0 deletions src/components/messages/HeardPathMap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# HeardPathMap

Leaflet map for the **message heard** dialog that shows **sender, feeders (receivers), and path geometry**: solid polylines where hop positions are known, dashed lines with hash tooltips where they are not.

Use this when each observation leg can be drawn as a geographic path from sender through waypoints to the hearing feeder—primarily **Meshtastic** heard UI today.

## When to use

| Use `HeardPathMap` | Use `HeardPathGeoMap` instead |
| ---------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Meshtastic `HeardDialog` branch in [`MessageItem.tsx`](./MessageItem.tsx) | MeshCore heard dialog top map (markers only; hops shown schematically below) |
| Legs include `waypoints` + `pathKnown` from `messageToHeardPathLegs` / `meshtasticHeardToLegs` | You only need sender + feeder pins with no on-map hop geometry |

These components are **not** drop-in replacements: `HeardPathMap` expects per-leg `HeardPathLeg` objects with waypoints; `HeardPathGeoMap` expects a flat `feeders[]` list.

## Props

| Prop | Type | Role |
| ------------ | ----------------------------- | ----------------------------------------------------------------- |
| `sender` | `{ label, position } \| null` | Path start marker (green) |
| `legs` | `HeardPathLeg[]` | One entry per feeder observation that has a **receiver position** |
| `senderName` | `string \| null` | Banner label when sender position is missing |

### `HeardPathLeg`

| Field | Role |
| ----------- | ------------------------------------------------------------------------- |
| `receiver` | Feeder marker (`label`, `position`) |
| `waypoints` | Intermediate hops as `TracerouteRouteNode[]` (`position` optional) |
| `pathKnown` | From API `path_known`: all hops resolved with positions |
| `lineColor` | Polyline and feeder marker colour (defaults from `HEARD_PATH_LEG_COLORS`) |

Legs without a receiver position are omitted upstream (`meshtasticHeardToLegs` / `meshCoreHeardToLegs`).

## What it draws

For each leg (feeder colour by index):

1. **Receiver marker** at `receiver.position`.
2. **Path lines** only if **sender position is known** (`senderPos`). If sender is missing, feeder markers still render but **no polylines** (banner: path lines omitted).
3. **Segment mode** (when `senderPos` exists):
- **`pathKnown` and at least one waypoint with `position`**: [`buildSegments`](../../../lib/map-path-segments.ts) — solid runs through known coordinates; dashed segments between known points with permanent tooltips showing `node_id_str` (hash) for unknown hops in that span.
- **Otherwise**: single **dashed** line sender → feeder; tooltips list all waypoint `node_id_str` values at the segment midpoint.

Sender marker uses `HEARD_PATH_SENDER_COLOR`. Map height **280px** (vs 240px on geo map).

## Empty and warning states

- **`legs.length === 0`**: placeholder — either “No feeder positions available for map” (sender known) or “No map — sender and feeder positions unknown”.
- **Sender missing, legs present**: amber overlay — sender position unknown; feeders shown; **path lines omitted**.

## Data adapters

| Adapter | Protocol | Output |
| ----------------------- | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `meshtasticHeardToLegs` | Meshtastic | Sender from `sender_position`; legs from `heard[]` with `observer_position`; empty `waypoints` |
| `meshCoreHeardToLegs` | MeshCore | Waypoints from `resolved_path` / `path_hashes`; used by tests and available for future MC map work—not wired in `MeshCoreHeardDialogBody` today |

Entry point: `messageToHeardPathLegs(message)` in [`heard-path-map-adapters.ts`](./heard-path-map-adapters.ts).

## Related

- [`HeardPathGeoMap.md`](./HeardPathGeoMap.md) — anchor-only map for MeshCore
- [`MeshCoreHeardPathsPanel.tsx`](./MeshCoreHeardPathsPanel.tsx) — schematic per-feeder hop chains (MC)
- [`docs/meshcore/heard-path-dialog.md`](../../../docs/meshcore/heard-path-dialog.md) — full dialog layout
81 changes: 11 additions & 70 deletions src/components/messages/HeardPathMap.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import { buildSegments, midpoint, type LatLng } from '@/lib/map-path-segments';
import type { LatLng } from '@/lib/map-path-segments';
import { MAP_NODE_MARKER_CSS } from '@/lib/map-marker-styles';
import type { TracerouteRouteNode } from '@/lib/models';
import { useMapTileUrl } from '@/hooks/useMapTileUrl';
import { createNodeIcon } from '@/components/nodes/map-utils';
import { drawHeardPathLayers, hasDrawablePathOnMap, toLatLng } from './heard-path-map-layers';
import L from 'leaflet';
import { useEffect, useRef } from 'react';
import 'leaflet/dist/leaflet.css';

import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants';

const DEFAULT_CENTER: LatLng = [55.8642, -4.2518];

export type MapPosition = { latitude: number; longitude: number };
Expand All @@ -27,14 +25,6 @@
senderName?: string | null;
};

function toLatLng(pos: MapPosition): LatLng {
return [pos.latitude, pos.longitude];
}

function hasPositionedWaypoints(waypoints: TracerouteRouteNode[]): boolean {
return waypoints.some((node) => node.position != null);
}

export function HeardPathMap({ sender, legs, senderName }: HeardPathMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<L.Map | null>(null);
Expand Down Expand Up @@ -63,7 +53,7 @@
style.remove();
};
}
}, []);

Check warning on line 56 in src/components/messages/HeardPathMap.tsx

View workflow job for this annotation

GitHub Actions / unit-tests

React Hook useEffect has missing dependencies: 'attribution' and 'tileUrl'. Either include them or remove the dependency array

useEffect(() => {
const map = mapInstanceRef.current;
Expand All @@ -83,63 +73,12 @@
layersRef.current = [];

const bounds = L.latLngBounds([]);

if (senderPos) {
const senderMarker = L.marker(senderPos, {
icon: createNodeIcon(sender?.label ?? 'S', HEARD_PATH_SENDER_COLOR, false),
}).addTo(map);
layersRef.current.push(senderMarker);
bounds.extend(senderPos);
}

legs.forEach((leg, index) => {
const receiverPos = toLatLng(leg.receiver.position);
const color = leg.lineColor ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length];
const receiverMarker = L.marker(receiverPos, {
icon: createNodeIcon(leg.receiver.label, color, false),
}).addTo(map);
layersRef.current.push(receiverMarker);
bounds.extend(receiverPos);

if (!senderPos) {
return;
}

const segments =
leg.pathKnown && hasPositionedWaypoints(leg.waypoints)
? buildSegments(senderPos, leg.waypoints, receiverPos)
: [
{
latlngs: [senderPos, receiverPos] as LatLng[],
dashed: true,
unknownLabels: leg.waypoints.map((node) => ({ node_id_str: node.node_id_str })),
},
];

segments.forEach((seg) => {
const poly = L.polyline(seg.latlngs, {
color,
weight: 4,
dashArray: seg.dashed ? '10, 10' : undefined,
}).addTo(map);
layersRef.current.push(poly);
seg.latlngs.forEach((p) => bounds.extend(p));

if (seg.dashed && seg.unknownLabels.length > 0 && seg.latlngs.length >= 2) {
const mid = midpoint(seg.latlngs[0], seg.latlngs[seg.latlngs.length - 1]);
seg.unknownLabels.forEach((label) => {
const tooltip = L.tooltip({
permanent: true,
direction: 'center',
className: 'traceroute-unknown-label',
})
.setContent(label.node_id_str)
.setLatLng(mid);
tooltip.addTo(map);
layersRef.current.push(tooltip);
});
}
});
drawHeardPathLayers({
map,
layers: layersRef.current,
bounds,
sender,
legs,
});

if (bounds.isValid()) {
Expand Down Expand Up @@ -168,6 +107,7 @@
}

const showSenderWarning = !senderPos;
const hasPartialPaths = !senderPos && hasDrawablePathOnMap(null, legs);
const warningLabel = senderName?.trim() || sender?.label;

return (
Expand All @@ -183,7 +123,8 @@
role="status"
>
Sender position unknown
{warningLabel ? ` (${warningLabel})` : ''} — feeders shown; path lines omitted.
{warningLabel ? ` (${warningLabel})` : ''} —
{hasPartialPaths ? ' paths use known hop and feeder positions only.' : ' feeders shown; path lines omitted.'}
</div>
)}
</div>
Expand Down
31 changes: 31 additions & 0 deletions src/components/messages/HopPositionIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { ResolvedHop } from '@/lib/models';
import { MapPin, MapPinOff } from 'lucide-react';
import { cn } from '@/lib/utils';

export type HopPositionIconProps = {
hop?: ResolvedHop | null;
positioned?: boolean;
className?: string;
};

export function HopPositionIcon({ hop, positioned, className }: HopPositionIconProps) {
const hasPosition = positioned ?? (hop?.status === 'resolved' && hop.position != null);
const ambiguous = hop?.status === 'ambiguous' || (hop?.candidates?.length ?? 0) > 1;

if (hasPosition) {
return (
<MapPin className={cn('h-3.5 w-3.5 shrink-0 text-emerald-600 dark:text-emerald-400', className)} aria-hidden />
);
}

return (
<MapPinOff
className={cn(
'h-3.5 w-3.5 shrink-0',
ambiguous ? 'text-amber-600 dark:text-amber-400' : 'text-muted-foreground',
className
)}
aria-hidden
/>
);
}
Loading
Loading