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
4 changes: 2 additions & 2 deletions ClientApps/trip-editor/src/map.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 8 additions & 8 deletions ClientApps/trip-editor/src/map/segmentPresentationLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

Expand Down Expand Up @@ -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('<br>')
: 'Route direction unavailable')
: 'Route direction unavailable', { className: 'trip-rich-tooltip' })
.addTo(group);
const chevrons = presentation.directionTrustworthy ? renderChevrons(presentation, active, group) : [];
const lineElement = line.getElement();
Expand Down Expand Up @@ -98,28 +97,29 @@ 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);
const layout = fitCombinedRouteBadgeLabels(labels, Math.min(160, mapBounds.right - mapBounds.left - 8));
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<typeof placeRouteBadge>, 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('<br>'), { className: 'trip-rich-tooltip' }).addTo(badgeGroup);
};

const rerenderForMovement = (): void => render(currentPresentations, currentActiveKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type ResolvedSegmentBadge = {
placeId: string;
location: readonly [number, number];
label: string;
descriptions: string[];
};

export type ResolvedSegmentAnchors = {
Expand Down Expand Up @@ -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(' → ');
Expand Down
47 changes: 47 additions & 0 deletions tests/client/segmentPresentationResolver.test.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import {
alphabeticAnchorLabel,
Expand Down Expand Up @@ -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\('<br>'\)/);
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 = [
Expand Down
52 changes: 45 additions & 7 deletions tests/client/tripViewerWaypointGap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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: () => ({
Expand All @@ -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;
};
Expand All @@ -201,24 +206,57 @@ 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']);
labels.length = 0;
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();

assert.equal(renderer.count(), 1);
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<br>B — Via 1 — Beta<br>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 — <Ella>', 'C — End — Ella & Co']
}]);
await renderer.waitForCurrent();

assert.equal(markers[0].marker.tooltip.content, 'A — Start — &lt;Ella&gt;<br>C — End — Ella &amp; 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. */
Expand All @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions wwwroot/js/Trip/segmentPresentation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions wwwroot/js/Trip/viewerSegmentBadgeRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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('<br>'), {
className: 'trip-rich-tooltip'
}).addTo(layer);
return waitForDecodedImage(marker.getElement?.());
};
Expand All @@ -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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;'
})[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.'));
Expand Down
Loading