diff --git a/ClientApps/trip-editor/src/map.css b/ClientApps/trip-editor/src/map.css
index 485132ca..5a37f5a5 100644
--- a/ClientApps/trip-editor/src/map.css
+++ b/ClientApps/trip-editor/src/map.css
@@ -93,11 +93,11 @@
z-index: 2;
}
-/* Route roles use an independent pointer-transparent channel beside canonical Place markers. */
+/* Route roles accept hover only within the painted badge bounds beside canonical Place markers. */
.segment-route-badge-wrapper {
background: transparent;
border: 0;
- pointer-events: none;
+ pointer-events: auto;
}
.segment-route-badge {
diff --git a/ClientApps/trip-editor/src/map/segmentPresentationLayer.ts b/ClientApps/trip-editor/src/map/segmentPresentationLayer.ts
index 1416f4ce..bd8a1628 100644
--- a/ClientApps/trip-editor/src/map/segmentPresentationLayer.ts
+++ b/ClientApps/trip-editor/src/map/segmentPresentationLayer.ts
@@ -28,7 +28,6 @@ export const createSegmentPresentationLayer = (
const pane = map.getPane('segment-route-role');
if (pane) {
pane.style.zIndex = '590';
- pane.style.pointerEvents = 'none';
pane.setAttribute('aria-hidden', 'true');
}
@@ -56,7 +55,7 @@ export const createSegmentPresentationLayer = (
.on('click', () => void onSelected(presentation.key))
.bindTooltip(presentation.directionTrustworthy
? presentation.anchors.anchors.map(anchor => `${anchor.label} — ${anchor.roleText} — ${anchor.displayName}`).join('
')
- : 'Route direction unavailable')
+ : 'Route direction unavailable', { className: 'trip-rich-tooltip' })
.addTo(group);
const chevrons = presentation.directionTrustworthy ? renderChevrons(presentation, active, group) : [];
const lineElement = line.getElement();
@@ -98,7 +97,7 @@ export const createSegmentPresentationLayer = (
}
placedBounds.push({ left: placement.left, top: placement.top,
right: placement.left + placement.width, bottom: placement.top + placement.height });
- renderBadgeMarker(badge.location, badge.label, anchor, placement);
+ renderBadgeMarker(badge.location, badge.label, badge.descriptions, anchor, placement);
});
if (blocked.length) {
const labels = blocked.map(item => item.badge.label);
@@ -106,20 +105,21 @@ export const createSegmentPresentationLayer = (
const label = labels.join('/');
const placement = placeCombinedRouteBadge(blocked.map(item => [item.anchor.x, item.anchor.y]),
layout, mapBounds, controlBounds, placedBounds);
- renderBadgeMarker(blocked[0].badge.location, label, blocked[0].anchor, placement, layout);
+ renderBadgeMarker(blocked[0].badge.location, label, blocked.flatMap(item => item.badge.descriptions),
+ blocked[0].anchor, placement, layout);
}
};
- /** Adds one pointer-transparent route-role badge without changing its canonical Place marker. */
- const renderBadgeMarker = (location: readonly [number, number], label: string, anchor: L.Point,
+ /** Adds one pointer-only route-role badge without changing its canonical Place marker. */
+ const renderBadgeMarker = (location: readonly [number, number], label: string, descriptions: readonly string[], anchor: L.Point,
placement: ReturnType, layout?: CombinedRouteBadgeLayout): void => {
L.marker([location[1], location[0]], {
pane: 'segment-route-role',
- interactive: false,
+ interactive: true,
keyboard: false,
alt: '',
icon: routeBadgeIcon(label, placement.left - anchor.x, placement.top - anchor.y, placement.fallback, layout)
- }).addTo(badgeGroup);
+ }).bindTooltip(descriptions.map(escapeHtml).join('
'), { className: 'trip-rich-tooltip' }).addTo(badgeGroup);
};
const rerenderForMovement = (): void => render(currentPresentations, currentActiveKey);
diff --git a/ClientApps/trip-editor/src/segments/segmentPresentationResolver.ts b/ClientApps/trip-editor/src/segments/segmentPresentationResolver.ts
index d497df6b..638dc80d 100644
--- a/ClientApps/trip-editor/src/segments/segmentPresentationResolver.ts
+++ b/ClientApps/trip-editor/src/segments/segmentPresentationResolver.ts
@@ -23,6 +23,7 @@ export type ResolvedSegmentBadge = {
placeId: string;
location: readonly [number, number];
label: string;
+ descriptions: string[];
};
export type ResolvedSegmentAnchors = {
@@ -194,11 +195,15 @@ export function resolveSegmentAnchors(inputs: readonly SegmentAnchorInput[]): Re
anchors.forEach(anchor => {
if (!anchor.placeId || !anchor.location) return;
const existing = badgeByPlace.get(anchor.placeId);
+ const description = `${anchor.label} — ${anchor.roleText} — ${anchor.displayName}`;
if (existing) {
existing.label = `${existing.label}/${anchor.label}`;
+ existing.descriptions.push(description);
return;
}
- badgeByPlace.set(anchor.placeId, { placeId: anchor.placeId, location: anchor.location, label: anchor.label });
+ badgeByPlace.set(anchor.placeId, {
+ placeId: anchor.placeId, location: anchor.location, label: anchor.label, descriptions: [description]
+ });
});
const compactTrail = anchors.map(anchor => `${anchor.label} ${anchor.displayName}`).join(' → ');
diff --git a/tests/client/segmentPresentationResolver.test.mjs b/tests/client/segmentPresentationResolver.test.mjs
index 00452514..3c72f7ee 100644
--- a/tests/client/segmentPresentationResolver.test.mjs
+++ b/tests/client/segmentPresentationResolver.test.mjs
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
+import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
alphabeticAnchorLabel,
@@ -77,6 +78,52 @@ test('combines closed-loop badge labels without duplicating the canonical Place'
assert.equal(classifySegmentOrientation(located, [[10, 20], [11, 21], [10, 20]], true), 'forward');
});
+/** Proves both transient resolvers retain complete ordered descriptions for badge hover. */
+test('retains complete ordered anchor descriptions in editor and viewer badges', async () => {
+ const viewer = await import(`../../wwwroot/js/Trip/segmentPresentation.js?descriptions=${Date.now()}`);
+ const inputs = [
+ anchor(0, 'ella', 'Ella', 'start'),
+ anchor(1, 'sri-pada', 'Sri Pada', 'via'),
+ anchor(2, 'kandy', 'Kandy', 'end')
+ ];
+ const viewerInputs = inputs.map(item => ({ ...item, longitude: item.location[0], latitude: item.location[1] }));
+ const expected = [
+ ['A — Start — Ella'],
+ ['B — Via 1 — Sri Pada'],
+ ['C — End — Kandy']
+ ];
+
+ assert.deepEqual(resolveSegmentAnchors(inputs).badges.map(item => item.descriptions), expected);
+ assert.deepEqual(viewer.resolveViewerAnchors(viewerInputs).badges.map(item => item.descriptions), expected);
+});
+
+/** Proves a reused Place keeps its combined label and every description in Segment order. */
+test('retains both ordered descriptions for a reused same-Place badge', async () => {
+ const viewer = await import(`../../wwwroot/js/Trip/segmentPresentation.js?samePlaceDescriptions=${Date.now()}`);
+ const inputs = [
+ anchor(0, 'ella', 'Ella', 'start'),
+ anchor(1, 'kandy', 'Kandy', 'via'),
+ anchor(2, 'ella', 'Ella', 'end')
+ ];
+ const viewerInputs = inputs.map(item => ({ ...item, longitude: item.location[0], latitude: item.location[1] }));
+ const expected = { label: 'A/C', descriptions: ['A — Start — Ella', 'C — End — Ella'] };
+
+ assert.deepEqual(resolveSegmentAnchors(inputs).badges[0], { placeId: 'ella', location: [10, 20], ...expected });
+ assert.deepEqual(viewer.resolveViewerAnchors(viewerInputs).badges[0], { placeId: 'ella', location: [10, 20], ...expected });
+});
+
+/** Pins the Editor's existing Leaflet route and badge tooltip boundary without a second Leaflet harness. */
+test('binds editor Segment and badge tooltips to the shared rich theme without keyboard badges', async () => {
+ const source = await readFile('ClientApps/trip-editor/src/map/segmentPresentationLayer.ts', 'utf8');
+ const css = await readFile('ClientApps/trip-editor/src/map.css', 'utf8');
+
+ assert.match(source, /\.bindTooltip\([^]*className:\s*'trip-rich-tooltip'/);
+ assert.match(source, /descriptions\.map\(escapeHtml\)\.join\('
'\)/);
+ assert.match(source, /interactive:\s*true,[^]*keyboard:\s*false/);
+ assert.doesNotMatch(source, /marker[^;]*\.on\(['"]click/);
+ assert.match(css, /\.segment-route-badge-wrapper\s*{[^}]*pointer-events:\s*auto/);
+});
+
/** Proves classification is deterministic and never mutates legacy geometry. */
test('classifies forward, reversed, and ambiguous legacy routes from semantic endpoints', () => {
const anchors = [
diff --git a/tests/client/tripViewerWaypointGap.test.mjs b/tests/client/tripViewerWaypointGap.test.mjs
index 6a789437..4187afc8 100644
--- a/tests/client/tripViewerWaypointGap.test.mjs
+++ b/tests/client/tripViewerWaypointGap.test.mjs
@@ -178,6 +178,7 @@ test('places viewer route badges with deterministic collision avoidance', async
test('combines all no-clear viewer badges into one meaningful fallback pill', async () => {
const labels = [];
const markers = [];
+ const tooltips = [];
prepareLeaflet();
globalThis.document.createElement = () => ({
width: 0, height: 0, getContext: () => ({
@@ -188,6 +189,10 @@ test('combines all no-clear viewer badges into one meaningful fallback pill', as
});
globalThis.L.marker = (_position, options) => {
const marker = presentationLayer({ complete: true, naturalWidth: 24, decode: () => Promise.resolve() })();
+ marker.bindTooltip = (content, tooltipOptions) => {
+ tooltips.push({ content, options: tooltipOptions });
+ return marker;
+ };
markers.push(options);
return marker;
};
@@ -201,7 +206,7 @@ test('combines all no-clear viewer badges into one meaningful fallback pill', as
});
const renderer = createViewerSegmentBadgeRenderer(map);
- renderer.render([{ label: 'A', location: [0, 0] }]);
+ renderer.render([{ label: 'A', location: [0, 0], descriptions: ['A — Start — Alpha'] }]);
await renderer.waitForCurrent();
assert.equal(renderer.count(), 1);
assert.deepEqual(labels, ['A']);
@@ -209,9 +214,9 @@ test('combines all no-clear viewer badges into one meaningful fallback pill', as
markers.length = 0;
renderer.render([
- { label: 'A', location: [0, 0] },
- { label: 'B', location: [1, 0] },
- { label: 'C', location: [2, 0] }
+ { label: 'A', location: [0, 0], descriptions: ['A — Start — Alpha'] },
+ { label: 'B', location: [1, 0], descriptions: ['B — Via 1 — Beta'] },
+ { label: 'C', location: [2, 0], descriptions: ['C — End — Gamma'] }
]);
await renderer.waitForCurrent();
@@ -219,6 +224,39 @@ test('combines all no-clear viewer badges into one meaningful fallback pill', as
assert.deepEqual(labels, ['A/B/C']);
assert.equal(markers.length, 1);
assert.match(markers[0].icon.className, /segment-route-badge-fallback/);
+ assert.deepEqual(tooltips.at(-1), {
+ content: 'A — Start — Alpha
B — Via 1 — Beta
C — End — Gamma',
+ options: { className: 'trip-rich-tooltip' }
+ });
+});
+
+/** Proves Viewer badge hover escapes each complete line and preserves non-keyboard ownership. */
+test('binds escaped rich tooltips to pointer-only viewer badges in Segment order', async () => {
+ const markers = [];
+ prepareLeaflet();
+ globalThis.L.marker = (_position, options) => {
+ const marker = presentationLayer({ complete: true, naturalWidth: 24, decode: () => Promise.resolve() })();
+ marker.bindTooltip = (content, tooltipOptions) => {
+ marker.tooltip = { content, options: tooltipOptions };
+ return marker;
+ };
+ markers.push({ marker, options });
+ return marker;
+ };
+ const { createViewerSegmentBadgeRenderer } = await import(`../../wwwroot/js/Trip/viewerSegmentBadgeRenderer.js?tooltips=${Date.now()}`);
+ const renderer = createViewerSegmentBadgeRenderer(presentationMap());
+
+ renderer.render([{
+ label: 'A/C', location: [0, 0],
+ descriptions: ['A — Start — ', 'C — End — Ella & Co']
+ }]);
+ await renderer.waitForCurrent();
+
+ assert.equal(markers[0].marker.tooltip.content, 'A — Start — <Ella>
C — End — Ella & Co');
+ assert.deepEqual(markers[0].marker.tooltip.options, { className: 'trip-rich-tooltip' });
+ assert.equal(markers[0].options.interactive, true);
+ assert.equal(markers[0].options.keyboard, false);
+ assert.equal(markers[0].marker.clickHandler, undefined);
});
/** Proves clear badges stay separate while only blocked labels combine and replacement remains bounded. */
@@ -242,9 +280,9 @@ test('preserves clear viewer badges and replaces only the blocked group', async
});
const renderer = createViewerSegmentBadgeRenderer(map);
const badges = [
- { label: 'A/C', location: [0, 0] },
- { label: 'B', location: [10, 0] },
- { label: 'D', location: [20, 0] }
+ { label: 'A/C', location: [0, 0], descriptions: ['A — Start — Alpha', 'C — End — Alpha'] },
+ { label: 'B', location: [10, 0], descriptions: ['B — Via 1 — Beta'] },
+ { label: 'D', location: [20, 0], descriptions: ['D — End — Delta'] }
];
renderer.render(badges);
diff --git a/wwwroot/js/Trip/segmentPresentation.js b/wwwroot/js/Trip/segmentPresentation.js
index f1a2515f..001437c6 100644
--- a/wwwroot/js/Trip/segmentPresentation.js
+++ b/wwwroot/js/Trip/segmentPresentation.js
@@ -25,8 +25,13 @@ export const resolveViewerAnchors = inputs => {
anchors.forEach(anchor => {
if (!anchor.placeId || !anchor.location) return;
const existing = badges.get(anchor.placeId);
- if (existing) existing.label += `/${anchor.label}`;
- else badges.set(anchor.placeId, { placeId: anchor.placeId, label: anchor.label, location: anchor.location });
+ const description = `${anchor.label} — ${anchor.roleText} — ${anchor.name}`;
+ if (existing) {
+ existing.label += `/${anchor.label}`;
+ existing.descriptions.push(description);
+ } else badges.set(anchor.placeId, {
+ placeId: anchor.placeId, label: anchor.label, location: anchor.location, descriptions: [description]
+ });
});
return {
anchors,
diff --git a/wwwroot/js/Trip/viewerSegmentBadgeRenderer.js b/wwwroot/js/Trip/viewerSegmentBadgeRenderer.js
index 84127134..379d3134 100644
--- a/wwwroot/js/Trip/viewerSegmentBadgeRenderer.js
+++ b/wwwroot/js/Trip/viewerSegmentBadgeRenderer.js
@@ -40,7 +40,8 @@ export const createViewerSegmentBadgeRenderer = map => {
const raster = routeBadgeDataUrl(label, layout);
const anchors = blocked.map(item => [item.anchor.x, item.anchor.y]);
const placement = placeCombinedRouteBadge(anchors, layout, mapBounds, controlBounds, placedBounds);
- images.push(renderMarker({...blocked[0].badge, label}, blocked[0].anchor, raster, placement));
+ const descriptions = blocked.flatMap(item => item.badge.descriptions);
+ images.push(renderMarker({...blocked[0].badge, label, descriptions}, blocked[0].anchor, raster, placement));
}
readiness = Promise.all(images).then(() => ({ok: true}), error => ({ok: false, error}));
return renderGeneration;
@@ -52,7 +53,9 @@ export const createViewerSegmentBadgeRenderer = map => {
icon: L.icon({iconUrl: raster.url, iconSize: [raster.width, raster.height],
iconAnchor: [anchor.x - placement.left, anchor.y - placement.top],
className: placement.fallback ? 'segment-route-badge-fallback' : ''}),
- interactive: false, keyboard: false, alt: ''
+ interactive: true, keyboard: false, alt: ''
+ }).bindTooltip(badge.descriptions.map(escapeHtml).join('
'), {
+ className: 'trip-rich-tooltip'
}).addTo(layer);
return waitForDecodedImage(marker.getElement?.());
};
@@ -76,6 +79,11 @@ export const createViewerSegmentBadgeRenderer = map => {
};
};
+/** Escapes one complete description at the final Leaflet HTML boundary. */
+const escapeHtml = value => value.replace(/[&<>"']/g, character => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+})[character]);
+
/** Uses decode when supported and an explicit complete/load fallback otherwise. */
const waitForDecodedImage = image => {
if (!image) return Promise.reject(new Error('Production route badge image was not attached.'));