@expofp/wayfinding 3.13.0 → 3.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,41 +8,41 @@ const POINT_EPS = 1e-6;
8
8
  const pointEquals = (a, b) => a.layer === b.layer && Math.abs(a.x - b.x) < POINT_EPS && Math.abs(a.y - b.y) < POINT_EPS;
9
9
  /**
10
10
  * Trim/extend the route's FROM tip to land exactly on `entry.projection`.
11
- * A* returns TO→FROM order, so the FROM boundary is the last line.
11
+ * The FROM boundary is the first line; `boundary.p0` is its graph node.
12
12
  * @param lines
13
13
  * @param entry
14
14
  */
15
15
  function attachFromEntry(lines, entry) {
16
16
  if (!lines.length)
17
17
  return lines;
18
- const boundary = lines[lines.length - 1];
19
- const chosenEndpoint = boundary.p1;
18
+ const boundary = lines[0];
19
+ const chosenEndpoint = boundary.p0;
20
20
  const otherEnd = pointEquals(entry.segment.p0, chosenEndpoint)
21
21
  ? entry.segment.p1
22
22
  : entry.segment.p0;
23
- if (pointEquals(boundary.p0, otherEnd)) {
24
- return [...lines.slice(0, -1), { ...boundary, p1: entry.projection }];
23
+ if (pointEquals(boundary.p1, otherEnd)) {
24
+ return [{ ...boundary, p0: entry.projection }, ...lines.slice(1)];
25
25
  }
26
- return [...lines, { ...entry.segment, p0: chosenEndpoint, p1: entry.projection }];
26
+ return [{ ...entry.segment, p0: entry.projection, p1: chosenEndpoint }, ...lines];
27
27
  }
28
28
  /**
29
- * Mirror of {@link attachFromEntry} for the TO side. TO boundary is the
30
- * FIRST line; `boundary.p0` is the chosen graph node on the TO end.
29
+ * Mirror of {@link attachFromEntry} for the TO side: the TO boundary is the
30
+ * last line and `boundary.p1` is its graph node.
31
31
  * @param lines
32
32
  * @param entry
33
33
  */
34
34
  function attachToEntry(lines, entry) {
35
35
  if (!lines.length)
36
36
  return lines;
37
- const boundary = lines[0];
38
- const chosenEndpoint = boundary.p0;
37
+ const boundary = lines[lines.length - 1];
38
+ const chosenEndpoint = boundary.p1;
39
39
  const otherEnd = pointEquals(entry.segment.p0, chosenEndpoint)
40
40
  ? entry.segment.p1
41
41
  : entry.segment.p0;
42
- if (pointEquals(boundary.p1, otherEnd)) {
43
- return [{ ...boundary, p0: entry.projection }, ...lines.slice(1)];
42
+ if (pointEquals(boundary.p0, otherEnd)) {
43
+ return [...lines.slice(0, -1), { ...boundary, p1: entry.projection }];
44
44
  }
45
- return [{ ...entry.segment, p0: entry.projection, p1: chosenEndpoint }, ...lines];
45
+ return [...lines, { ...entry.segment, p0: chosenEndpoint, p1: entry.projection }];
46
46
  }
47
47
  function attachOffGraphEntries(lines, fromEntry, toEntry) {
48
48
  let result = lines;
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Default A* cost for a virtual (floor-transition) link. Non-zero so a floor
3
- * change costs more than any same-floor detour; otherwise floor changes are free
4
- * and a route between two points on one floor can detour through another.
2
+ * Default A* cost for an unset virtual link that changes layers. Being far
3
+ * larger than any physical segment cost, it makes the pathfinder minimize the
4
+ * number of floor changes, so a route only crosses a floor when needed to reach
5
+ * the destination. A per-line `virtualLength` overrides it (designer control).
5
6
  */
6
- export declare const VIRTUAL_LINE_PENALTY = 10000;
7
+ export declare const FLOOR_TRANSITION_PENALTY = 10000;
8
+ /** Default A* cost for a virtual link that stays on the same layer (free). */
9
+ export declare const VIRTUAL_LINE_PENALTY = 0;
7
10
  /**
8
11
  * Fallback line weight when `line.weight` is 0 (meaning "not set" in floorplan data,
9
12
  * not "free passage").
@@ -1,9 +1,12 @@
1
1
  /**
2
- * Default A* cost for a virtual (floor-transition) link. Non-zero so a floor
3
- * change costs more than any same-floor detour; otherwise floor changes are free
4
- * and a route between two points on one floor can detour through another.
2
+ * Default A* cost for an unset virtual link that changes layers. Being far
3
+ * larger than any physical segment cost, it makes the pathfinder minimize the
4
+ * number of floor changes, so a route only crosses a floor when needed to reach
5
+ * the destination. A per-line `virtualLength` overrides it (designer control).
5
6
  */
6
- export const VIRTUAL_LINE_PENALTY = 10000;
7
+ export const FLOOR_TRANSITION_PENALTY = 10000;
8
+ /** Default A* cost for a virtual link that stays on the same layer (free). */
9
+ export const VIRTUAL_LINE_PENALTY = 0;
7
10
  /**
8
11
  * Fallback line weight when `line.weight` is 0 (meaning "not set" in floorplan data,
9
12
  * not "free passage").
@@ -1,6 +1,8 @@
1
1
  import { type GraphInstance, type GraphLine, type RoutePoint, type ShortestPathResult } from '../types.js';
2
2
  /**
3
3
  * Computes the weighted path cost matching the graph link distance formula.
4
+ * Floor-change surcharges are already baked into `linkCost`, so this simply
5
+ * sums the per-edge cost.
4
6
  * @param points
5
7
  * @param lines
6
8
  */
@@ -6,6 +6,8 @@ function createRoutePoint(layer, x, y) {
6
6
  }
7
7
  /**
8
8
  * Computes the weighted path cost matching the graph link distance formula.
9
+ * Floor-change surcharges are already baked into `linkCost`, so this simply
10
+ * sums the per-edge cost.
9
11
  * @param points
10
12
  * @param lines
11
13
  */
@@ -1,8 +1,11 @@
1
1
  import { degToRad, lineAngle, pointDistance } from '@expofp/geometry';
2
- export const toNodeId = (p) => `${p.layer}_${p.x}_${p.y}`;
2
+ export const toNodeId = (p) => `${p.layer ?? ''}_${p.x}_${p.y}`;
3
3
  /** Maximum distance (SVG units) to consider two graph nodes as the same point. */
4
4
  const POINT_PROXIMITY_THRESHOLD = 1;
5
- export const arePointsClose = (p1, p2) => p1.layer === p2.layer && pointDistance(p1, p2) <= POINT_PROXIMITY_THRESHOLD;
5
+ // `RoutePoint.layer` is typed as a string, but external data may still supply a
6
+ // nullish/invalid layer. Normalize it to '' so point comparisons stay consistent
7
+ // with the ids that toNodeId/parseNodeId round-trip to.
8
+ export const arePointsClose = (p1, p2) => (p1.layer ?? '') === (p2.layer ?? '') && pointDistance(p1, p2) <= POINT_PROXIMITY_THRESHOLD;
6
9
  export const matchesLine = (line, p0, p1) => (arePointsClose(line.p0, p0) && arePointsClose(line.p1, p1)) ||
7
10
  (arePointsClose(line.p0, p1) && arePointsClose(line.p1, p0));
8
11
  export function findLineByEndpoints(lines, p0, p1) {
@@ -1,6 +1,12 @@
1
1
  import { type GraphLine } from '../types.js';
2
+ export declare function changesLayer(line: GraphLine): boolean;
2
3
  /**
3
- * Weighted cost of a route line for A* graph edge (legacy formula).
4
+ * Weighted cost of a graph edge for A*.
5
+ *
6
+ * Physical links cost their weighted length. A virtual link uses its configured
7
+ * `virtualLength` when set (designer control); otherwise a layer-changing link
8
+ * costs `FLOOR_TRANSITION_PENALTY` (routes avoid needless floor changes) and a
9
+ * same-layer link is free (`VIRTUAL_LINE_PENALTY`).
4
10
  * @param line
5
11
  */
6
12
  export declare function linkCost(line: GraphLine): number;
@@ -1,11 +1,24 @@
1
1
  import { pointDistance } from '@expofp/geometry';
2
- import { DEFAULT_LINE_WEIGHT, VIRTUAL_LINE_PENALTY } from './constants.js';
2
+ import { DEFAULT_LINE_WEIGHT, FLOOR_TRANSITION_PENALTY, VIRTUAL_LINE_PENALTY, } from './constants.js';
3
+ function physicalCost(line) {
4
+ return pointDistance(line.p0, line.p1) / (line.weight || DEFAULT_LINE_WEIGHT);
5
+ }
6
+ export function changesLayer(line) {
7
+ return (line.p0.layer ?? '') !== (line.p1.layer ?? '');
8
+ }
3
9
  /**
4
- * Weighted cost of a route line for A* graph edge (legacy formula).
10
+ * Weighted cost of a graph edge for A*.
11
+ *
12
+ * Physical links cost their weighted length. A virtual link uses its configured
13
+ * `virtualLength` when set (designer control); otherwise a layer-changing link
14
+ * costs `FLOOR_TRANSITION_PENALTY` (routes avoid needless floor changes) and a
15
+ * same-layer link is free (`VIRTUAL_LINE_PENALTY`).
5
16
  * @param line
6
17
  */
7
18
  export function linkCost(line) {
8
- if (line.virtual)
9
- return line.virtualLength ?? VIRTUAL_LINE_PENALTY;
10
- return pointDistance(line.p0, line.p1) / (line.weight || DEFAULT_LINE_WEIGHT);
19
+ if (!line.virtual)
20
+ return physicalCost(line);
21
+ if (line.virtualLength != null)
22
+ return line.virtualLength;
23
+ return changesLayer(line) ? FLOOR_TRANSITION_PENALTY : VIRTUAL_LINE_PENALTY;
11
24
  }
@@ -41,8 +41,9 @@ export function createAStarPathFinder() {
41
41
  return !!graph.hasNode(id);
42
42
  },
43
43
  /**
44
- * Returns path nodes in **target→source** order (A* reconstructs from the goal);
45
- * downstream consumers depend on this ordering.
44
+ * Returns path nodes in **source→target** order (`fromId` first, `toId`
45
+ * last). A* reconstructs from the goal, so the raw path is target→source; it
46
+ * is reversed here once, at the source, so no downstream reversal is needed.
46
47
  * @param fromId
47
48
  * @param toId
48
49
  */
@@ -52,10 +53,12 @@ export function createAStarPathFinder() {
52
53
  const nodes = finder.find(fromId, toId);
53
54
  if (!nodes.length)
54
55
  return [];
55
- return nodes.map((node) => {
56
+ return nodes
57
+ .map((node) => {
56
58
  const [layer, x, y] = parseNodeId(node.id);
57
59
  return { id: node.id, layer, x, y };
58
- });
60
+ })
61
+ .reverse();
59
62
  },
60
63
  };
61
64
  }
@@ -6,7 +6,6 @@ export { projectPointOnSegment } from './geometry/projectPointOnSegment.js';
6
6
  export { computeTrailPoints } from './rendering/computeTrailPoints.js';
7
7
  export type { TransitionPointDef } from './rendering/computeTransitionPoints.js';
8
8
  export { computeTransitionPoints } from './rendering/computeTransitionPoints.js';
9
- export { normalizeRouteDirection } from './rendering/normalizeRouteDirection.js';
10
9
  export { computeBoundingBox } from './rendering/routeGeometry.js';
11
10
  export { getRouteLength } from './routing/getRouteLength.js';
12
11
  export type { GpsProjectionConfig } from './position/gpsThreshold.js';
@@ -3,7 +3,6 @@ export { projectPointOnSegment } from './geometry/projectPointOnSegment.js';
3
3
  // Rendering utilities
4
4
  export { computeTrailPoints } from './rendering/computeTrailPoints.js';
5
5
  export { computeTransitionPoints } from './rendering/computeTransitionPoints.js';
6
- export { normalizeRouteDirection } from './rendering/normalizeRouteDirection.js';
7
6
  export { computeBoundingBox } from './rendering/routeGeometry.js';
8
7
  // Routing utilities
9
8
  export { getRouteLength } from './routing/getRouteLength.js';
@@ -1,4 +1,9 @@
1
1
  import { type RouteLine, type RoutePoint, type RouteSnapResult, type SnapToRouteConfig } from '../types.js';
2
2
  export { SNAP_THRESHOLD_METERS } from './gpsThreshold.js';
3
- export declare function snapToRoute(position: RoutePoint, routeLines: RouteLine[], config: SnapToRouteConfig): RouteSnapResult;
3
+ /**
4
+ * Snap a position onto a from→to route polyline. Pure geometry: nearest segment
5
+ * within `snapThreshold`, plus the remaining arc-length to the destination.
6
+ * Segments on a layer other than `position.layer` are skipped.
7
+ */
8
+ export declare function snapToRoute(position: RoutePoint, routeLines: readonly RouteLine[], { snapThreshold }: SnapToRouteConfig): RouteSnapResult;
4
9
  //# sourceMappingURL=snapToRoute.d.ts.map
@@ -1,38 +1,18 @@
1
1
  import { pointDistance } from '@expofp/geometry';
2
- import { pointInPolygon } from '../geometry/pointInPolygon.js';
3
2
  import { projectPointOnSegment } from '../geometry/projectPointOnSegment.js';
4
- function pointAtDistanceSafe(routeLines, distanceFromStart) {
5
- let acc = 0;
6
- for (const line of routeLines) {
7
- const len = pointDistance(line.p0, line.p1);
8
- if (len === 0)
9
- continue;
10
- if (acc + len >= distanceFromStart) {
11
- const t = (distanceFromStart - acc) / len;
12
- const x = line.p0.x + (line.p1.x - line.p0.x) * t;
13
- const y = line.p0.y + (line.p1.y - line.p0.y) * t;
14
- return {
15
- point: { layer: line.p0.layer, x, y },
16
- line,
17
- };
18
- }
19
- acc += len;
20
- }
21
- const last = routeLines[routeLines.length - 1];
22
- return {
23
- point: { layer: last.p0.layer, x: last.p1.x, y: last.p1.y },
24
- line: last,
25
- };
26
- }
27
3
  export { SNAP_THRESHOLD_METERS } from './gpsThreshold.js';
28
- export function snapToRoute(position, routeLines, config) {
4
+ /**
5
+ * Snap a position onto a from→to route polyline. Pure geometry: nearest segment
6
+ * within `snapThreshold`, plus the remaining arc-length to the destination.
7
+ * Segments on a layer other than `position.layer` are skipped.
8
+ */
9
+ export function snapToRoute(position, routeLines, { snapThreshold }) {
29
10
  const lineCount = routeLines.length;
30
11
  if (!lineCount)
31
12
  return { snapped: false };
32
- const { snapThreshold, to, from, minRemainingUnits = 0.5 } = config;
33
13
  const posLayer = position.layer || null;
34
14
  let bestDistance = Infinity;
35
- let bestResult = null;
15
+ let best = null;
36
16
  let accumulatedLength = 0;
37
17
  let hasSegmentOnPosLayer = posLayer == null;
38
18
  for (let i = 0; i < lineCount; i++) {
@@ -49,66 +29,22 @@ export function snapToRoute(position, routeLines, config) {
49
29
  const projection = projectPointOnSegment({ x: position.x, y: position.y }, p0, p1);
50
30
  if (projection.distance < bestDistance) {
51
31
  bestDistance = projection.distance;
52
- bestResult = {
53
- segmentIndex: i,
54
- t: projection.t,
55
- distanceFromPolylineStart: accumulatedLength + segLen * projection.t,
32
+ best = {
33
+ point: { layer: p0.layer, x: projection.projected.x, y: projection.projected.y },
34
+ line,
35
+ fromStart: accumulatedLength + segLen * projection.t,
56
36
  };
57
37
  }
58
38
  accumulatedLength += segLen;
59
39
  }
60
40
  if (posLayer != null && !hasSegmentOnPosLayer)
61
41
  return { snapped: false };
62
- const totalLen = accumulatedLength;
63
- const routeStart = routeLines[0].p0;
64
- const routeEnd = routeLines[lineCount - 1].p1;
65
- const distDestToStart = pointDistance(to, routeStart);
66
- const distDestToEnd = pointDistance(to, routeEnd);
67
- const destinationAtPolylineStart = distDestToStart <= distDestToEnd;
68
- const minRemain = Math.min(minRemainingUnits, totalLen);
69
- if (from?.bounds &&
70
- (posLayer == null || !from.layer || from.layer === posLayer) &&
71
- pointInPolygon({ x: position.x, y: position.y }, from.bounds)) {
72
- const remainingToDestination = totalLen;
73
- const distanceFromStart = destinationAtPolylineStart
74
- ? remainingToDestination
75
- : totalLen - remainingToDestination;
76
- const final = pointAtDistanceSafe(routeLines, distanceFromStart);
77
- return {
78
- snapped: true,
79
- snappedPoint: final.point,
80
- snappedLine: final.line,
81
- distance: remainingToDestination,
82
- };
83
- }
84
- if (to.bounds &&
85
- (posLayer == null || !to.layer || to.layer === posLayer) &&
86
- pointInPolygon({ x: position.x, y: position.y }, to.bounds)) {
87
- const remainingToDestination = minRemain;
88
- const passedSvgLength = totalLen - remainingToDestination;
89
- const distanceFromStart = destinationAtPolylineStart ? remainingToDestination : passedSvgLength;
90
- const final = pointAtDistanceSafe(routeLines, distanceFromStart);
91
- return {
92
- snapped: true,
93
- snappedPoint: final.point,
94
- snappedLine: final.line,
95
- distance: remainingToDestination,
96
- };
97
- }
98
- if (!bestResult || bestDistance > snapThreshold)
42
+ if (!best || bestDistance > snapThreshold)
99
43
  return { snapped: false };
100
- let remainingToDestination = destinationAtPolylineStart
101
- ? bestResult.distanceFromPolylineStart
102
- : totalLen - bestResult.distanceFromPolylineStart;
103
- if (remainingToDestination < minRemain)
104
- remainingToDestination = minRemain;
105
- const passedSvgLength = totalLen - remainingToDestination;
106
- const distanceFromStart = destinationAtPolylineStart ? remainingToDestination : passedSvgLength;
107
- const final = pointAtDistanceSafe(routeLines, distanceFromStart);
108
44
  return {
109
45
  snapped: true,
110
- snappedPoint: final.point,
111
- snappedLine: final.line,
112
- distance: remainingToDestination,
46
+ snappedPoint: best.point,
47
+ snappedLine: best.line,
48
+ distance: accumulatedLength - best.fromStart,
113
49
  };
114
50
  }
@@ -92,9 +92,9 @@ export function computeTransitionPoints(input) {
92
92
  layer: visible.layer,
93
93
  targetLayer: resolvedTarget,
94
94
  direction: resolveDirection(floorOrder, visible.layer, resolvedTarget),
95
- // When currentLayerName is null, pickP0 is always true — default to "exit"
96
- // to preserve existing behavior (all points show stairs icon).
97
- role: currentLayerName ? (pickP0 ? 'entry' : 'exit') : 'exit',
95
+ // Current-floor endpoint at `p0` → route departs here → "exit" (clickable
96
+ // to advance); at `p1` → arrives here → "entry". Null layer → default "exit".
97
+ role: currentLayerName ? (pickP0 ? 'exit' : 'entry') : 'exit',
98
98
  };
99
99
  });
100
100
  // 5. Deduplicate by coordinates. Drop entries that collide with an exit
@@ -28,16 +28,8 @@ export function buildMultiPointRoute(graph, candidateGroups) {
28
28
  for (let i = 0; i < reachable.length - 1; i++) {
29
29
  segmentResults.push(buildRoute(graph, reachable[i], reachable[i + 1]));
30
30
  }
31
- // A* returns each leg in target→source order (see aStarPathFinder.find()).
32
- // For candidates [A, B, C], legs are computed as:
33
- // buildRoute(A, B) → lines [B→...→A]
34
- // buildRoute(B, C) → lines [C→...→B]
35
- //
36
- // Without reverse: allLines = [B→...→A, C→...→B] — legs are disconnected.
37
- // With reverse: allLines = [C→...→B, B→...→A] — endpoint B connects the
38
- // chain, creating the continuous target→source polyline that
39
- // route animation and snapping logic expect.
40
- segmentResults.reverse();
31
+ // Legs concatenate directly: for [A, B, C], buildRoute(A, B) → [A→…→B] and
32
+ // buildRoute(B, C) → [B→…→C] share endpoint B, forming one continuous polyline.
41
33
  const allLines = segmentResults.flatMap((result) => result.lines);
42
34
  const totalDistance = segmentResults.reduce((sum, result) => sum + result.totalDistance, 0);
43
35
  return { lines: allLines, totalDistance };
@@ -1,3 +1,3 @@
1
1
  import { type RouteLine } from '../types.js';
2
- export declare function getRouteLength(routeLines: RouteLine[]): number;
2
+ export declare function getRouteLength(routeLines: readonly RouteLine[]): number;
3
3
  //# sourceMappingURL=getRouteLength.d.ts.map
@@ -15,7 +15,7 @@ export interface RoutePoint {
15
15
  export interface RouteLine {
16
16
  readonly p0: RoutePoint;
17
17
  readonly p1: RoutePoint;
18
- /** Virtual (floor-transition) link rather than a physical path segment. */
18
+ /** Virtual link rather than a physical path segment. May or may not change layers. */
19
19
  readonly virtual: boolean;
20
20
  }
21
21
  /**
@@ -31,11 +31,10 @@ export interface GraphLine extends RouteLine {
31
31
  /** Link weight for cost (`0` = unset → `DEFAULT_LINE_WEIGHT`). */
32
32
  readonly weight: number;
33
33
  /**
34
- * Optional custom cost for virtual (floor-transition) links, configured
35
- * per-line in the designer as "Virtual length". When set, the pathfinder
36
- * uses this value instead of the default VIRTUAL_LINE_PENALTY, overriding it
37
- * per line — `0` makes the transition free (preferred), a large value makes
38
- * it avoided. When unset, the non-zero default keeps routes on the same floor.
34
+ * Per-line virtual cost from the designer ("Virtual length"). When set it
35
+ * replaces the default policy (`0` = free/preferred, large = avoided);
36
+ * otherwise a layer-changing link costs `FLOOR_TRANSITION_PENALTY`, a
37
+ * same-layer one is free.
39
38
  */
40
39
  readonly virtualLength?: number;
41
40
  }
@@ -99,8 +98,5 @@ export interface RouteEndpoint extends RoutePoint {
99
98
  }
100
99
  export interface SnapToRouteConfig {
101
100
  readonly snapThreshold: number;
102
- readonly minRemainingUnits?: number;
103
- readonly to: RouteEndpoint;
104
- readonly from?: RouteEndpoint;
105
101
  }
106
102
  //# sourceMappingURL=types.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { Box } from '@expofp/geometry';
2
- import { createRerouteController, getRouteLength, snapToRoute, } from '../core/index.js';
2
+ import { createRerouteController, getRouteLength, } from '../core/index.js';
3
3
  import { createEndpointView } from './endpointView.js';
4
4
  import { getRouteLines } from './getRouteLines.js';
5
5
  import { createPositionTrailView } from './positionTrailView.js';
@@ -7,6 +7,7 @@ import { CURRENT_POSITION_POINT_ID } from './positionView.js';
7
7
  import { createRouteLinesView } from './routeLinesView.js';
8
8
  import { computeRouteRenderData } from './routeRenderData.js';
9
9
  import { computeRouteUpdate } from './routeUpdate.js';
10
+ import { snapPositionToRoute } from './snapPositionToRoute.js';
10
11
  import { createTrailView } from './trailView.js';
11
12
  import { createTransitionView } from './transitionView.js';
12
13
  const linesToPoints = (lines) => lines.flatMap((line) => [line.p0, line.p1]);
@@ -84,8 +85,6 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
84
85
  const data = computeRouteRenderData({
85
86
  routeLines,
86
87
  currentRouteLayer: getCurrentRouteLayer(),
87
- from: currentFrom,
88
- to: currentTo,
89
88
  snap,
90
89
  });
91
90
  routeLinesView.draw(data, preserveProgress);
@@ -110,12 +109,14 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
110
109
  // `hasSnappedCurrentRoute` only flips for a live-CP FROM (booth/kiosk origins never snap),
111
110
  // so it already implies a CP origin — no need to re-check the endpoint id here.
112
111
  const fromAnchor = hasSnappedCurrentRoute ? null : pickAnchor(route.from);
112
+ // The trail tip is the route's graph endpoint on the same side as its
113
+ // off-graph anchor: first point for FROM, last point for TO.
113
114
  if (fromAnchor) {
114
- positionTrails.setTrail(route.routePoints[route.routePoints.length - 1], fromAnchor);
115
+ positionTrails.setTrail(route.routePoints[0], fromAnchor);
115
116
  return;
116
117
  }
117
118
  const toAnchor = pickAnchor(route.to);
118
- positionTrails.setTrail(toAnchor ? route.routePoints[0] : null, toAnchor);
119
+ positionTrails.setTrail(toAnchor ? route.routePoints[route.routePoints.length - 1] : null, toAnchor);
119
120
  }
120
121
  function syncRouteVisuals() {
121
122
  if (!currentFrom || !currentTo)
@@ -157,11 +158,7 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
157
158
  : // Selected floor doesn't pass through the route — view has nothing
158
159
  // to draw, but distance must still update. Snap against the full
159
160
  // non-virtual route directly.
160
- snapToRoute(currentPosition, nonVirtual, {
161
- snapThreshold,
162
- from: currentFrom,
163
- to: currentTo,
164
- });
161
+ snapPositionToRoute(currentPosition, nonVirtual, currentFrom, currentTo, snapThreshold);
165
162
  if (result.snapped)
166
163
  hasSnappedCurrentRoute = true;
167
164
  snappedToRoute = result.snapped;
@@ -10,8 +10,8 @@ export function createEndpointView({ renderer, iconProvider, floorContext, layer
10
10
  return;
11
11
  const fromOnFloor = floorContext.isLayerVisible(from.layer);
12
12
  const toOnFloor = floorContext.isLayerVisible(to.layer);
13
- const sourcePosition = visibleRoutePoints[visibleRoutePoints.length - 1];
14
- const destPosition = visibleRoutePoints[0];
13
+ const sourcePosition = visibleRoutePoints[0];
14
+ const destPosition = visibleRoutePoints[visibleRoutePoints.length - 1];
15
15
  const sourceInactive = from.id === CURRENT_POSITION_POINT_ID && snapped;
16
16
  renderer.setIcon('source', {
17
17
  canvas: iconProvider.getIcon(sourceInactive ? 'source_inactive' : 'source').canvas,
@@ -1,5 +1,5 @@
1
- import { snapToRoute, } from '../core/index.js';
2
1
  import { createPositionView } from './positionView.js';
2
+ import { snapPositionToRoute } from './snapPositionToRoute.js';
3
3
  const TRAIL_SLOT = 'trail';
4
4
  export function createPositionTrailView({ renderer, iconProvider, floorContext, trails, iconLayer, snapThreshold, }) {
5
5
  const positionIcon = createPositionView({ renderer, iconProvider, layer: iconLayer });
@@ -12,11 +12,7 @@ export function createPositionTrailView({ renderer, iconProvider, floorContext,
12
12
  positionIcon.hide();
13
13
  },
14
14
  applyToRoute(position, route, routeLines) {
15
- const snap = snapToRoute(position, [...routeLines], {
16
- snapThreshold,
17
- from: route.from,
18
- to: route.to,
19
- });
15
+ const snap = snapPositionToRoute(position, routeLines, route.from, route.to, snapThreshold);
20
16
  const hidden = !floorContext.isLayerVisible(position.layer);
21
17
  if (snap.snapped) {
22
18
  positionIcon.place({ ...position, x: snap.snappedPoint.x, y: snap.snappedPoint.y }, hidden);
@@ -1,4 +1,4 @@
1
- import { type RouteEndpoint, type RouteLine, type RouteSnapResult } from '../core/index.js';
1
+ import { type RouteLine, type RouteSnapResult } from '../core/index.js';
2
2
  interface RouteRenderData {
3
3
  /** Route lines behind the current user position (empty when no snap). */
4
4
  readonly passed: RouteLine[];
@@ -12,23 +12,19 @@ interface RouteRenderData {
12
12
  * passed/remaining split for the visible floor plus the remaining
13
13
  * distance to the destination.
14
14
  *
15
- * The full non-virtual route is normalized to from→to direction once and
16
- * sliced down to the visible floor for rendering. The remaining distance
17
- * is read from `snap.distance` (computed once inside `snapToRoute`); when
18
- * no snap is active we fall back to the full route length.
15
+ * The full non-virtual route is sliced down to the visible floor for rendering.
16
+ * The remaining distance is read from `snap.distance` (computed once by the
17
+ * runtime snap, `snapPositionToRoute`); with no snap it falls back to the full
18
+ * route length.
19
19
  * @param root0
20
20
  * @param root0.routeLines
21
21
  * @param root0.currentRouteLayer
22
- * @param root0.from
23
- * @param root0.to
24
22
  * @param root0.snap
25
23
  */
26
- export declare function computeRouteRenderData({ routeLines, currentRouteLayer, from, to, snap, }: {
24
+ export declare function computeRouteRenderData({ routeLines, currentRouteLayer, snap, }: {
27
25
  readonly routeLines: RouteLine[];
28
26
  /** null = show-all mode; otherwise the active route floor. */
29
27
  readonly currentRouteLayer: string | null;
30
- readonly from: RouteEndpoint;
31
- readonly to: RouteEndpoint;
32
28
  readonly snap: RouteSnapResult | null;
33
29
  }): RouteRenderData;
34
30
  export {};
@@ -1,22 +1,20 @@
1
- import { getRouteLength, normalizeRouteDirection, splitRouteByPoint, } from '../core/index.js';
1
+ import { getRouteLength, splitRouteByPoint, } from '../core/index.js';
2
2
  /**
3
3
  * Pure pipeline that produces the data needed to render the route: the
4
4
  * passed/remaining split for the visible floor plus the remaining
5
5
  * distance to the destination.
6
6
  *
7
- * The full non-virtual route is normalized to from→to direction once and
8
- * sliced down to the visible floor for rendering. The remaining distance
9
- * is read from `snap.distance` (computed once inside `snapToRoute`); when
10
- * no snap is active we fall back to the full route length.
7
+ * The full non-virtual route is sliced down to the visible floor for rendering.
8
+ * The remaining distance is read from `snap.distance` (computed once by the
9
+ * runtime snap, `snapPositionToRoute`); with no snap it falls back to the full
10
+ * route length.
11
11
  * @param root0
12
12
  * @param root0.routeLines
13
13
  * @param root0.currentRouteLayer
14
- * @param root0.from
15
- * @param root0.to
16
14
  * @param root0.snap
17
15
  */
18
- export function computeRouteRenderData({ routeLines, currentRouteLayer, from, to, snap, }) {
19
- const nonVirtualOrdered = normalizeRouteDirection(routeLines.filter((line) => !line.virtual), from, to);
16
+ export function computeRouteRenderData({ routeLines, currentRouteLayer, snap, }) {
17
+ const nonVirtualOrdered = routeLines.filter((line) => !line.virtual);
20
18
  const clipToVisibleFloor = (lines) => currentRouteLayer === null
21
19
  ? lines
22
20
  : lines.filter((line) => line.p0.layer === currentRouteLayer);
@@ -34,7 +32,10 @@ export function computeRouteRenderData({ routeLines, currentRouteLayer, from, to
34
32
  // Ref-equal result means no segment matched the snap layer (bestIndex === -1); only on a
35
33
  // real split do we anchor the first remaining segment at the snap point.
36
34
  const splitOccurred = remaining !== nonVirtualOrdered;
37
- const adjustedRemaining = splitOccurred && remaining.length > 0
35
+ // Anchor only when `remaining[0]` is on the snap's floor — at a transition it
36
+ // already belongs to the next floor, and keeping its own `p0` survives the clip.
37
+ const anchorAtSnap = splitOccurred && remaining.length > 0 && remaining[0].p0.layer === snap.snappedPoint.layer;
38
+ const adjustedRemaining = anchorAtSnap
38
39
  ? [{ ...remaining[0], p0: snap.snappedPoint }, ...remaining.slice(1)]
39
40
  : remaining;
40
41
  return {
@@ -0,0 +1,10 @@
1
+ import { type RouteEndpoint, type RouteLine, type RoutePoint, type RouteSnapResult } from '../core/index.js';
2
+ /**
3
+ * Snap a position to the from→to route, resolving from/to booth containment
4
+ * first (domain logic kept out of the geometric `snapToRoute`):
5
+ * - inside the origin booth → route start, full distance remaining;
6
+ * - inside the destination booth → route end, zero remaining;
7
+ * - otherwise → geometric snap within `snapThreshold`.
8
+ */
9
+ export declare function snapPositionToRoute(position: RoutePoint, routeLines: readonly RouteLine[], from: RouteEndpoint, to: RouteEndpoint, snapThreshold: number): RouteSnapResult;
10
+ //# sourceMappingURL=snapPositionToRoute.d.ts.map
@@ -0,0 +1,36 @@
1
+ import { pointInPolygon } from '../core/geometry/pointInPolygon.js';
2
+ import { getRouteLength, snapToRoute, } from '../core/index.js';
3
+ /** Whether `position` lies inside `endpoint`'s booth bounds on a matching layer. */
4
+ function insideBounds(position, endpoint, posLayer) {
5
+ if (!endpoint?.bounds)
6
+ return false;
7
+ const layerMatches = posLayer == null || !endpoint.layer || endpoint.layer === posLayer;
8
+ const containsPosition = pointInPolygon({ x: position.x, y: position.y }, endpoint.bounds);
9
+ return layerMatches && containsPosition;
10
+ }
11
+ /**
12
+ * Snap a position to the from→to route, resolving from/to booth containment
13
+ * first (domain logic kept out of the geometric `snapToRoute`):
14
+ * - inside the origin booth → route start, full distance remaining;
15
+ * - inside the destination booth → route end, zero remaining;
16
+ * - otherwise → geometric snap within `snapThreshold`.
17
+ */
18
+ export function snapPositionToRoute(position, routeLines, from, to, snapThreshold) {
19
+ if (!routeLines.length)
20
+ return { snapped: false };
21
+ const posLayer = position.layer || null;
22
+ if (insideBounds(position, from, posLayer)) {
23
+ const first = routeLines[0];
24
+ return {
25
+ snapped: true,
26
+ snappedPoint: first.p0,
27
+ snappedLine: first,
28
+ distance: getRouteLength(routeLines),
29
+ };
30
+ }
31
+ if (insideBounds(position, to, posLayer)) {
32
+ const last = routeLines[routeLines.length - 1];
33
+ return { snapped: true, snappedPoint: last.p1, snappedLine: last, distance: 0 };
34
+ }
35
+ return snapToRoute(position, routeLines, { snapThreshold });
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/wayfinding",
3
- "version": "3.13.0",
3
+ "version": "3.13.1",
4
4
  "type": "module",
5
5
  "description": "ExpoFP SDK internal: framework-neutral wayfinding (routing, snapping, scene rendering)",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -34,7 +34,7 @@
34
34
  "dependencies": {
35
35
  "ngraph.graph": "^19.1.0",
36
36
  "tslib": "^2.3.0",
37
- "@expofp/geometry": "3.13.0"
37
+ "@expofp/geometry": "3.13.1"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@expofp/renderer": "^3.2.4"
@@ -1,19 +0,0 @@
1
- import type { RouteLine, RoutePoint } from '../types.js';
2
- /**
3
- * Ensures that `routeLines` run in the from→to direction.
4
- *
5
- * A* returns paths in target→source order. For rendering (arrow animation,
6
- * route progress split) and remaining-distance computation the lines must
7
- * go from the origin to the destination.
8
- *
9
- * Direction is decided by projecting `from` (or `to` as a fallback) onto
10
- * `routeLines`: if the anchor lands closer to the polyline end than to
11
- * its start, the array is reversed and each segment's endpoints are
12
- * flipped to keep continuity.
13
- * @param routeLines Lines to normalize.
14
- * @param from Route origin (user-selected "from" point).
15
- * @param to Route destination (user-selected "to" point).
16
- * @returns Lines guaranteed to run from→to, or the original array unchanged.
17
- */
18
- export declare function normalizeRouteDirection(routeLines: RouteLine[], from: RoutePoint, to: RoutePoint): RouteLine[];
19
- //# sourceMappingURL=normalizeRouteDirection.d.ts.map
@@ -1,82 +0,0 @@
1
- import { pointDistance } from '@expofp/geometry';
2
- /**
3
- * Projects `point` onto the closest segment of `segments` that shares the same layer,
4
- * returning the arc-length distance from the polyline start to the projection.
5
- * @param point
6
- * @param segments
7
- * @param segmentLengths
8
- */
9
- function projectPointOnRoute(point, segments, segmentLengths) {
10
- let accumulatedLength = 0;
11
- let bestDistance = Infinity;
12
- let bestFromStart = Infinity;
13
- const total = segmentLengths.reduce((sum, len) => sum + len, 0);
14
- for (let i = 0; i < segments.length; i++) {
15
- const segment = segments[i];
16
- const segmentLength = segmentLengths[i];
17
- // Empty `point.layer` = wildcard (single-floor plans without layers).
18
- if (point.layer && segment.p0.layer !== point.layer) {
19
- accumulatedLength += segmentLength;
20
- continue;
21
- }
22
- const dx = segment.p1.x - segment.p0.x;
23
- const dy = segment.p1.y - segment.p0.y;
24
- const lengthSquared = dx * dx + dy * dy;
25
- // Near-zero-length segment — nothing meaningful to project onto.
26
- if (lengthSquared < 1e-10) {
27
- accumulatedLength += segmentLength;
28
- continue;
29
- }
30
- // Project point onto segment, clamped to [0, 1].
31
- const t = ((point.x - segment.p0.x) * dx + (point.y - segment.p0.y) * dy) / lengthSquared;
32
- const clamped = Math.max(0, Math.min(1, t));
33
- const projectedX = segment.p0.x + dx * clamped;
34
- const projectedY = segment.p0.y + dy * clamped;
35
- const distance = Math.hypot(point.x - projectedX, point.y - projectedY);
36
- if (distance < bestDistance) {
37
- bestDistance = distance;
38
- bestFromStart = accumulatedLength + segmentLength * clamped;
39
- }
40
- accumulatedLength += segmentLength;
41
- }
42
- return { found: bestFromStart !== Infinity, fromStart: bestFromStart, total };
43
- }
44
- /**
45
- * Ensures that `routeLines` run in the from→to direction.
46
- *
47
- * A* returns paths in target→source order. For rendering (arrow animation,
48
- * route progress split) and remaining-distance computation the lines must
49
- * go from the origin to the destination.
50
- *
51
- * Direction is decided by projecting `from` (or `to` as a fallback) onto
52
- * `routeLines`: if the anchor lands closer to the polyline end than to
53
- * its start, the array is reversed and each segment's endpoints are
54
- * flipped to keep continuity.
55
- * @param routeLines Lines to normalize.
56
- * @param from Route origin (user-selected "from" point).
57
- * @param to Route destination (user-selected "to" point).
58
- * @returns Lines guaranteed to run from→to, or the original array unchanged.
59
- */
60
- export function normalizeRouteDirection(routeLines, from, to) {
61
- // Precompute segment lengths once (used for both projections).
62
- const segmentLengths = routeLines.map((line) => pointDistance(line.p0, line.p1));
63
- // Try to anchor using `from`. Fall back to `to` if `from` doesn't land on any same-layer segment.
64
- const fromProjection = projectPointOnRoute(from, routeLines, segmentLengths);
65
- const anchor = fromProjection.found
66
- ? fromProjection
67
- : projectPointOnRoute(to, routeLines, segmentLengths);
68
- if (!anchor.found)
69
- return routeLines;
70
- const distToStart = anchor.fromStart;
71
- const distToEnd = anchor.total - anchor.fromStart;
72
- // If `from` was used as anchor: `from` should be closer to the start.
73
- // If `to` was used as fallback: `to` should be closer to the end.
74
- const shouldReverse = fromProjection.found ? distToEnd < distToStart : distToStart < distToEnd;
75
- if (!shouldReverse)
76
- return routeLines;
77
- // Reverse segment order AND flip each segment's endpoints to maintain continuity.
78
- return routeLines
79
- .slice()
80
- .reverse()
81
- .map((line) => ({ ...line, p0: line.p1, p1: line.p0 }));
82
- }