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.'));