+
+
{observerLink ? (
{leg.receiverLabel}
diff --git a/src/components/messages/PathHopBadge.tsx b/src/components/messages/PathHopBadge.tsx
index 6028301..939ce40 100644
--- a/src/components/messages/PathHopBadge.tsx
+++ b/src/components/messages/PathHopBadge.tsx
@@ -4,13 +4,25 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import type { ResolvedHop } from '@/lib/models';
import { nodeDetailPath } from '@/lib/node-detail-routes';
import { cn } from '@/lib/utils';
+import { HopPositionIcon } from './HopPositionIcon';
function hopIsLinkable(hop: ResolvedHop): boolean {
return hop.status === 'resolved' && Boolean(hop.node_id_str?.trim());
}
function hopLabel(hop: ResolvedHop): string {
- return hop.long_name?.trim() || hop.hash;
+ return hop.short_name?.trim() || hop.long_name?.trim() || hop.hash;
+}
+
+function hopTooltip(hop: ResolvedHop): string | null {
+ if (hop.candidates && hop.candidates.length > 0) {
+ const names = hop.candidates.map((c) => c.short_name || c.long_name || c.node_id_str).join(', ');
+ return `${hop.candidates.length} possible nodes: ${names}`;
+ }
+ if (hop.ambiguous) {
+ return 'Multiple matches possible';
+ }
+ return null;
}
export function PathHopBadge({ hop }: { hop: ResolvedHop }) {
@@ -28,22 +40,30 @@ export function PathHopBadge({ hop }: { hop: ResolvedHop }) {
);
- const wrapped =
- hop.ambiguous && unknown ? (
+ const tooltipText = hopTooltip(hop);
+ const wrappedBadge =
+ tooltipText != null ? (
{badge}
- Multiple matches possible
+ {tooltipText}
) : (
badge
);
+ const content = (
+
+
+ {wrappedBadge}
+
+ );
+
if (!hopIsLinkable(hop)) {
- return wrapped;
+ return content;
}
const path = nodeDetailPath({
@@ -53,16 +73,16 @@ export function PathHopBadge({ hop }: { hop: ResolvedHop }) {
});
if (!path) {
- return wrapped;
+ return content;
}
return (
e.stopPropagation()}
>
- {wrapped}
+ {content}
);
}
diff --git a/src/components/messages/heard-path-map-adapters.test.ts b/src/components/messages/heard-path-map-adapters.test.ts
index adbe819..bc2c338 100644
--- a/src/components/messages/heard-path-map-adapters.test.ts
+++ b/src/components/messages/heard-path-map-adapters.test.ts
@@ -3,6 +3,7 @@ import {
meshCoreHeardLegs,
meshCoreHeardToLegs,
meshtasticHeardToLegs,
+ resolveHeardPathSender,
resolvedHopsFromObservation,
} from './heard-path-map-adapters';
import type { TextMessage } from '@/lib/models';
@@ -46,6 +47,39 @@ describe('heard path adapters', () => {
expect(legs[0].waypoints).toHaveLength(0);
});
+ it('resolveHeardPathSender identifies single mc_sender_candidate without position', () => {
+ const message: TextMessage = {
+ id: 'gi7',
+ packet_id: 1,
+ protocol: 'meshcore',
+ sender: null,
+ mc_sender_label: '☘️GI7ULG☘️',
+ mc_sender_candidates: [
+ {
+ internal_id: '00000000-0000-4000-8000-000000000099',
+ node_id_str: 'mc:9cce73b9b3ee',
+ long_name: '☘️GI7ULG☘️',
+ short_name: '☘️GI7',
+ position: null,
+ },
+ ],
+ recipient_meshtastic_node_id: null,
+ channel: 1,
+ sent_at: new Date().toISOString(),
+ message_text: '☘️GI7ULG☘️: Test',
+ is_emoji: false,
+ reply_to_meshtastic_packet_id: null,
+ heard: [],
+ };
+ const sender = resolveHeardPathSender(message);
+ expect(sender?.identified).toBe(true);
+ expect(sender?.label).toBe('☘️GI7');
+ expect(sender?.position).toBeNull();
+ expect(sender?.detailPath).toBe('/nodes/mc%3A9cce73b9b3ee');
+ const { sender: geoSender } = meshCoreHeardToLegs(message);
+ expect(geoSender).toBeNull();
+ });
+
it('meshCoreHeardToLegs uses single mc_sender_candidate with position as sender', () => {
const message: TextMessage = {
id: '3',
@@ -76,7 +110,7 @@ describe('heard path adapters', () => {
expect(sender?.position.latitude).toBe(55.0);
});
- it('meshCoreHeardToLegs omits sender when multiple positioned candidates', () => {
+ it('meshCoreHeardToLegs omits geo sender when multiple mc_sender_candidates', () => {
const pos = { latitude: 55.0, longitude: -4.0 };
const message: TextMessage = {
id: '4',
@@ -347,6 +381,71 @@ describe('heard path adapters', () => {
expect(legs[0].waypoints[0].short_name).toBe('Hop1');
});
+ it('meshCoreHeardToLegs omits ambiguous hops from map waypoints', () => {
+ const message: TextMessage = {
+ id: '8',
+ packet_id: 8,
+ protocol: 'meshcore',
+ sender: { node_id_str: 'mc:abc', long_name: 'X', short_name: 'X' },
+ sender_position: { latitude: 55.0, longitude: -4.0 },
+ recipient_meshtastic_node_id: null,
+ channel: 1,
+ sent_at: new Date().toISOString(),
+ message_text: 'mc',
+ is_emoji: false,
+ reply_to_meshtastic_packet_id: null,
+ heard: [
+ {
+ observer: {
+ node_id_str: 'mc:feed',
+ internal_id: null,
+ long_name: 'Feeder',
+ short_name: 'F',
+ position: { latitude: 55.2, longitude: -4.2 },
+ },
+ rx_time: new Date().toISOString(),
+ rx_rssi: -90,
+ rx_snr: 2,
+ path_hashes: ['aa', 'bb'],
+ resolved_path: [
+ {
+ hash: 'aa',
+ status: 'resolved',
+ node_id_str: 'mc:hop',
+ internal_id: '1',
+ long_name: 'Hop',
+ short_name: 'H',
+ ambiguous: false,
+ position: { latitude: 55.1, longitude: -4.1 },
+ },
+ {
+ hash: 'bb',
+ status: 'ambiguous',
+ node_id_str: null,
+ internal_id: null,
+ long_name: null,
+ short_name: null,
+ ambiguous: true,
+ candidates: [
+ {
+ internal_id: '2',
+ node_id_str: 'mc:x',
+ long_name: 'X1',
+ short_name: 'X1',
+ position: null,
+ },
+ ],
+ },
+ ],
+ path_known: false,
+ },
+ ],
+ };
+ const { legs } = meshCoreHeardToLegs(message);
+ expect(legs[0].waypoints).toHaveLength(1);
+ expect(legs[0].waypoints[0].node_id_str).toBe('mc:hop');
+ });
+
it('resolvedHopsFromObservation falls back to path_hashes', () => {
const hops = resolvedHopsFromObservation({
observer: {
diff --git a/src/components/messages/heard-path-map-adapters.ts b/src/components/messages/heard-path-map-adapters.ts
index cf4e9c5..b1f1833 100644
--- a/src/components/messages/heard-path-map-adapters.ts
+++ b/src/components/messages/heard-path-map-adapters.ts
@@ -7,6 +7,7 @@ import type {
TextMessage,
} from '@/lib/models';
import type { TracerouteRouteNode } from '@/lib/models';
+import { nodeDetailPath } from '@/lib/node-detail-routes';
import { HEARD_PATH_LEG_COLORS } from './heard-path-constants';
import type { HeardPathLeg } from './HeardPathMap';
@@ -41,48 +42,100 @@ export function resolvedHopsFromObservation(obs: MeshCoreHeardObservation): Reso
}));
}
-function senderFromMcCandidates(candidates: McSenderCandidate[] | undefined): {
+/** Sender for heard dialog: identity vs map position are separate. */
+export type HeardPathSender = {
label: string;
- position: MapPosition;
-} | null {
- if (!candidates?.length) return null;
- const positioned = candidates.filter((c) => c.position != null) as Array<
- McSenderCandidate & { position: MapPosition }
- >;
- if (positioned.length !== 1) return null;
- const node = positioned[0];
- return {
- label: node.short_name || node.long_name || node.node_id_str,
- position: node.position,
- };
+ position: MapPosition | null;
+ /** True when the sender node is identified (not ambiguous). */
+ identified: boolean;
+ detailPath: string | null;
+};
+
+function mcCandidateDetailPath(candidate: McSenderCandidate): string | null {
+ return nodeDetailPath({
+ internal_id: candidate.internal_id ?? undefined,
+ node_id_str: candidate.node_id_str,
+ protocol: 2,
+ });
}
-export function mapHeardPathSender(message: TextMessage): { label: string; position: MapPosition } | null {
- if (message.sender && message.sender_position) {
+function senderFromMcCandidates(
+ candidates: McSenderCandidate[] | undefined,
+ fallbackLabel?: string
+): HeardPathSender | null {
+ if (!candidates?.length) {
+ if (fallbackLabel) {
+ return { label: fallbackLabel, position: null, identified: false, detailPath: null };
+ }
+ return null;
+ }
+ if (candidates.length === 1) {
+ const node = candidates[0];
return {
- label: message.sender.short_name || message.sender.node_id_str,
- position: message.sender_position,
+ label: node.short_name || node.long_name || node.node_id_str,
+ position: node.position ?? null,
+ identified: true,
+ detailPath: mcCandidateDetailPath(node),
};
}
+ const label =
+ fallbackLabel || candidates[0].short_name || candidates[0].long_name || candidates[0].node_id_str || 'Sender';
+ return { label, position: null, identified: false, detailPath: null };
+}
+
+export function resolveHeardPathSender(message: TextMessage): HeardPathSender | null {
+ const fallbackLabel = message.mc_sender_label?.trim();
+ const proto = message.protocol?.toString().toLowerCase();
+ const isMeshCore = proto === 'meshcore' || proto === '2';
+
+ if (message.sender?.node_id_str) {
+ return {
+ label: message.sender.short_name || message.sender.long_name || message.sender.node_id_str,
+ position: message.sender_position ?? null,
+ identified: true,
+ detailPath: nodeDetailPath({
+ node_id_str: message.sender.node_id_str,
+ protocol: isMeshCore ? 2 : 1,
+ }),
+ };
+ }
+
if (message.sender_position) {
return {
- label: message.sender?.short_name || message.mc_sender_label || message.sender?.node_id_str || 'Sender',
+ label: message.sender?.short_name || fallbackLabel || message.sender?.node_id_str || 'Sender',
position: message.sender_position,
+ identified: true,
+ detailPath: message.sender?.node_id_str
+ ? nodeDetailPath({ node_id_str: message.sender.node_id_str, protocol: isMeshCore ? 2 : 1 })
+ : null,
};
}
- return senderFromMcCandidates(message.mc_sender_candidates);
+
+ return senderFromMcCandidates(message.mc_sender_candidates, fallbackLabel);
+}
+
+/** Geo map anchor when sender has coordinates only. */
+export function heardPathSenderForGeoMap(
+ sender: HeardPathSender | null
+): { label: string; position: MapPosition } | null {
+ if (!sender?.position) return null;
+ return { label: sender.label, position: sender.position };
}
-export function heardPathSenderDisplayLabel(
- message: TextMessage,
- sender: { label: string; position: MapPosition } | null
-): string {
+export function mapHeardPathSender(message: TextMessage): { label: string; position: MapPosition } | null {
+ return heardPathSenderForGeoMap(resolveHeardPathSender(message));
+}
+
+export function heardPathSenderDisplayLabel(message: TextMessage, sender: HeardPathSender | null): string {
if (sender?.label) return sender.label;
return message.mc_sender_label?.trim() || message.sender?.short_name || message.sender?.node_id_str || 'Sender';
}
-function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode {
- const label = hop.long_name || hop.node_id_str || hop.hash;
+function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode | null {
+ if (hop.status === 'ambiguous') {
+ return null;
+ }
+ const label = hop.short_name || hop.long_name || hop.node_id_str || hop.hash;
return {
meshtastic_node_id: UNKNOWN_NODE_ID,
node_id_str: hop.node_id_str || hop.hash,
@@ -92,11 +145,11 @@ function hopToWaypoint(hop: ResolvedHop): TracerouteRouteNode {
}
export function meshCoreHeardLegs(message: TextMessage): {
- sender: { label: string; position: MapPosition } | null;
+ sender: HeardPathSender | null;
senderDisplayLabel: string;
legs: MeshCoreHeardLeg[];
} {
- const sender = mapHeardPathSender(message);
+ const sender = resolveHeardPathSender(message);
const senderDisplayLabel = heardPathSenderDisplayLabel(message, sender);
const legs: MeshCoreHeardLeg[] = [];
let colorIndex = 0;
@@ -145,13 +198,15 @@ export function meshCoreHeardToLegs(message: TextMessage): {
sender: { label: string; position: MapPosition } | null;
legs: HeardPathLeg[];
} {
- const { sender, legs } = meshCoreHeardLegs(message);
+ const { legs } = meshCoreHeardLegs(message);
+ const sender = heardPathSenderForGeoMap(resolveHeardPathSender(message));
const mapLegs: HeardPathLeg[] = [];
for (const leg of legs) {
if (!leg.receiverPosition) continue;
+ const waypoints = leg.hops.map(hopToWaypoint).filter((node): node is TracerouteRouteNode => node != null);
mapLegs.push({
receiver: { label: leg.receiverLabel, position: leg.receiverPosition },
- waypoints: leg.hops.map(hopToWaypoint),
+ waypoints,
pathKnown: leg.pathKnown,
lineColor: leg.lineColor,
});
@@ -170,6 +225,17 @@ export function messageToHeardPathLegs(message: TextMessage): {
return meshtasticHeardToLegs(message);
}
+export function messageHasAmbiguousPathHops(message: TextMessage): boolean {
+ for (const obs of message.heard ?? []) {
+ if (!isMeshCoreHeardObservation(obs)) continue;
+ const hops = resolvedHopsFromObservation(obs);
+ if (hops.some((hop) => hop.status === 'ambiguous' || (hop.candidates?.length ?? 0) > 1)) {
+ return true;
+ }
+ }
+ return false;
+}
+
export function isMeshCoreHeardMessage(message: TextMessage): boolean {
const proto = message.protocol?.toString().toLowerCase();
if (proto === 'meshcore' || proto === '2') return true;
diff --git a/src/components/messages/heard-path-map-layers.ts b/src/components/messages/heard-path-map-layers.ts
new file mode 100644
index 0000000..7961b1f
--- /dev/null
+++ b/src/components/messages/heard-path-map-layers.ts
@@ -0,0 +1,145 @@
+import { buildPartialSegments, midpoint, type LatLng, type PathSegment } from '@/lib/map-path-segments';
+import { createNodeIcon } from '@/components/nodes/map-utils';
+import type { MapPosition } from './HeardPathMap';
+import type { HeardPathLeg } from './HeardPathMap';
+import { HEARD_PATH_LEG_COLORS, HEARD_PATH_SENDER_COLOR } from './heard-path-constants';
+import L from 'leaflet';
+
+export function toLatLng(pos: MapPosition): LatLng {
+ return [pos.latitude, pos.longitude];
+}
+
+export function planLegPathSegments(senderPos: LatLng | null, leg: HeardPathLeg): PathSegment[] {
+ const receiverPos = toLatLng(leg.receiver.position);
+ const hasPositionedHop = leg.waypoints.some((node) => node.position != null);
+
+ if (!senderPos && !hasPositionedHop) {
+ return [];
+ }
+
+ if (leg.pathKnown && hasPositionedHop && senderPos) {
+ return buildPartialSegments({
+ startPos: senderPos,
+ waypoints: leg.waypoints,
+ endPos: receiverPos,
+ truncateAfterLastKnown: false,
+ });
+ }
+
+ return buildPartialSegments({
+ startPos: senderPos,
+ waypoints: leg.waypoints,
+ endPos: receiverPos,
+ truncateAfterLastKnown: true,
+ });
+}
+
+function unknownLabelContent(labels: { node_id_str: string }[]): string {
+ return labels.map((l) => l.node_id_str).join(' · ');
+}
+
+export function addPathSegmentsToMap(map: L.Map, layers: L.Layer[], segments: PathSegment[], color: string): void {
+ segments.forEach((seg) => {
+ const poly = L.polyline(seg.latlngs, {
+ color,
+ weight: 4,
+ dashArray: seg.dashed ? '10, 10' : undefined,
+ }).addTo(map);
+ layers.push(poly);
+
+ if (seg.dashed && seg.unknownLabels.length > 0 && seg.latlngs.length >= 2) {
+ const mid = midpoint(seg.latlngs[0], seg.latlngs[seg.latlngs.length - 1]);
+ const tooltip = L.tooltip({
+ permanent: true,
+ direction: 'center',
+ className: 'traceroute-unknown-label',
+ })
+ .setContent(unknownLabelContent(seg.unknownLabels))
+ .setLatLng(mid);
+ tooltip.addTo(map);
+ layers.push(tooltip);
+ }
+ });
+}
+
+export type DrawHeardPathLayersOptions = {
+ map: L.Map;
+ layers: L.Layer[];
+ bounds: L.LatLngBounds;
+ sender: { label: string; position: MapPosition } | null;
+ legs: HeardPathLeg[];
+};
+
+function drawLegPolylinesAndHopMarkers(
+ map: L.Map,
+ layers: L.Layer[],
+ bounds: L.LatLngBounds,
+ senderPos: LatLng | null,
+ legs: HeardPathLeg[],
+ options: { includeReceiverMarkers: boolean }
+): boolean {
+ let drewPath = false;
+
+ legs.forEach((leg, index) => {
+ const receiverPos = toLatLng(leg.receiver.position);
+ const color = leg.lineColor ?? HEARD_PATH_LEG_COLORS[index % HEARD_PATH_LEG_COLORS.length];
+
+ if (options.includeReceiverMarkers) {
+ const receiverMarker = L.marker(receiverPos, {
+ icon: createNodeIcon(leg.receiver.label, color, false),
+ }).addTo(map);
+ layers.push(receiverMarker);
+ bounds.extend(receiverPos);
+ }
+
+ leg.waypoints.forEach((node) => {
+ if (!node.position) return;
+ const pos = toLatLng(node.position);
+ const label = node.short_name || node.node_id_str;
+ const hopMarker = L.marker(pos, {
+ icon: createNodeIcon(label, color, false),
+ }).addTo(map);
+ layers.push(hopMarker);
+ bounds.extend(pos);
+ });
+
+ const segments = planLegPathSegments(senderPos, leg);
+ if (segments.length > 0) {
+ drewPath = true;
+ addPathSegmentsToMap(map, layers, segments, color);
+ segments.forEach((seg) => {
+ seg.latlngs.forEach((p) => bounds.extend(p));
+ });
+ }
+ });
+
+ return drewPath;
+}
+
+export function drawHeardPathLayers({ map, layers, bounds, sender, legs }: DrawHeardPathLayersOptions): boolean {
+ const senderPos = sender ? toLatLng(sender.position) : null;
+
+ if (senderPos) {
+ const senderMarker = L.marker(senderPos, {
+ icon: createNodeIcon(sender?.label ?? 'S', HEARD_PATH_SENDER_COLOR, false),
+ }).addTo(map);
+ layers.push(senderMarker);
+ bounds.extend(senderPos);
+ }
+
+ return drawLegPolylinesAndHopMarkers(map, layers, bounds, senderPos, legs, { includeReceiverMarkers: true });
+}
+
+export function drawHeardPathPolylinesOnly(
+ map: L.Map,
+ layers: L.Layer[],
+ bounds: L.LatLngBounds,
+ senderPos: LatLng | null,
+ legs: HeardPathLeg[]
+): boolean {
+ return drawLegPolylinesAndHopMarkers(map, layers, bounds, senderPos, legs, { includeReceiverMarkers: false });
+}
+
+export function hasDrawablePathOnMap(senderPos: LatLng | null, legs: HeardPathLeg[]): boolean {
+ return legs.some((leg) => planLegPathSegments(senderPos, leg).length > 0);
+}
diff --git a/src/lib/map-path-segments.test.ts b/src/lib/map-path-segments.test.ts
index 77f8f59..9f2c419 100644
--- a/src/lib/map-path-segments.test.ts
+++ b/src/lib/map-path-segments.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { buildSegments } from './map-path-segments';
+import { buildPartialSegments, buildSegments } from './map-path-segments';
import type { TracerouteRouteNode } from '@/lib/models';
const start: [number, number] = [55.0, -4.0];
@@ -34,3 +34,52 @@ describe('buildSegments', () => {
expect(segments.some((s) => !s.dashed)).toBe(true);
});
});
+
+describe('buildPartialSegments', () => {
+ it('starts at first positioned hop when sender is missing', () => {
+ const nodes: TracerouteRouteNode[] = [
+ {
+ meshtastic_node_id: 1,
+ node_id_str: 'hop',
+ short_name: 'H',
+ position: { latitude: 55.05, longitude: -4.05 },
+ },
+ ];
+ const segments = buildPartialSegments({
+ startPos: null,
+ waypoints: nodes,
+ endPos: end,
+ });
+ expect(segments.length).toBeGreaterThan(0);
+ });
+
+ it('omits line to feeder when unknown hops trail last positioned hop', () => {
+ const nodes: TracerouteRouteNode[] = [
+ {
+ meshtastic_node_id: 1,
+ node_id_str: 'known',
+ short_name: 'K',
+ position: { latitude: 55.05, longitude: -4.05 },
+ },
+ {
+ meshtastic_node_id: 2,
+ node_id_str: 'unknown',
+ short_name: 'unknown',
+ position: null,
+ },
+ ];
+ const segments = buildPartialSegments({
+ startPos: start,
+ waypoints: nodes,
+ endPos: end,
+ truncateAfterLastKnown: true,
+ });
+ const reachesFeeder = segments.some(
+ (s) =>
+ s.latlngs.length > 0 &&
+ s.latlngs[s.latlngs.length - 1][0] === end[0] &&
+ s.latlngs[s.latlngs.length - 1][1] === end[1]
+ );
+ expect(reachesFeeder).toBe(false);
+ });
+});
diff --git a/src/lib/map-path-segments.ts b/src/lib/map-path-segments.ts
index d2382d5..c5eba13 100644
--- a/src/lib/map-path-segments.ts
+++ b/src/lib/map-path-segments.ts
@@ -56,3 +56,85 @@ export function buildSegments(
export function midpoint(a: LatLng, b: LatLng): LatLng {
return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
}
+
+function toLatLngFromNode(node: TracerouteRouteNode): LatLng {
+ return [node.position!.latitude, node.position!.longitude];
+}
+
+export type PartialSegmentParams = {
+ startPos: LatLng | null;
+ waypoints: TracerouteRouteNode[];
+ endPos: LatLng | null;
+ truncateAfterLastKnown?: boolean;
+};
+
+/**
+ * Build path segments when start and/or end may be missing and trailing hops may lack positions.
+ * Omits lines past the last positioned intermediate when truncateAfterLastKnown is true.
+ */
+export function buildPartialSegments({
+ startPos,
+ waypoints,
+ endPos,
+ truncateAfterLastKnown = true,
+}: PartialSegmentParams): PathSegment[] {
+ let nodes = waypoints;
+ let end = endPos;
+
+ if (truncateAfterLastKnown && endPos) {
+ let lastPositionedIdx = -1;
+ for (let i = waypoints.length - 1; i >= 0; i -= 1) {
+ if (waypoints[i].position) {
+ lastPositionedIdx = i;
+ break;
+ }
+ }
+ if (lastPositionedIdx >= 0) {
+ const trailingUnknown = waypoints.slice(lastPositionedIdx + 1).some((w) => !w.position);
+ if (trailingUnknown) {
+ end = null;
+ nodes = waypoints.slice(0, lastPositionedIdx + 1);
+ }
+ } else if (!startPos) {
+ return [];
+ }
+ }
+
+ let start = startPos;
+ if (!start) {
+ const firstIdx = nodes.findIndex((w) => w.position);
+ if (firstIdx < 0) {
+ return [];
+ }
+ start = toLatLngFromNode(nodes[firstIdx]);
+ nodes = nodes.slice(firstIdx + 1);
+ }
+
+ if (!start) {
+ return [];
+ }
+
+ if (!end) {
+ const lastPositioned = [...nodes].reverse().find((w) => w.position);
+ if (!lastPositioned?.position) {
+ return [];
+ }
+ const endAnchor = toLatLngFromNode(lastPositioned);
+ const lastIdx = nodes.indexOf(lastPositioned);
+ const chain = nodes.slice(0, lastIdx + 1);
+ return buildSegments(start, chain, endAnchor);
+ }
+
+ const hasPositionedHop = nodes.some((w) => w.position);
+ if (!hasPositionedHop) {
+ return [
+ {
+ latlngs: [start, end],
+ dashed: true,
+ unknownLabels: waypoints.map((node) => ({ node_id_str: node.node_id_str })),
+ },
+ ];
+ }
+
+ return buildSegments(start, nodes, end);
+}
diff --git a/src/lib/models.ts b/src/lib/models.ts
index 239d0d2..0846b79 100644
--- a/src/lib/models.ts
+++ b/src/lib/models.ts
@@ -416,8 +416,10 @@ export interface ResolvedHop {
node_id_str: string | null;
internal_id: string | null;
long_name: string | null;
+ short_name?: string | null;
ambiguous: boolean;
position?: MapPosition | null;
+ candidates?: McSenderCandidate[];
}
export interface HeardObserver {