@expofp/wayfinding 3.11.11 → 3.12.0

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
- * The FROM boundary is the first line; `boundary.p0` is its graph node.
11
+ * A* returns TO→FROM order, so the FROM boundary is the last line.
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[0];
19
- const chosenEndpoint = boundary.p0;
18
+ const boundary = lines[lines.length - 1];
19
+ const chosenEndpoint = boundary.p1;
20
20
  const otherEnd = pointEquals(entry.segment.p0, chosenEndpoint)
21
21
  ? entry.segment.p1
22
22
  : entry.segment.p0;
23
- if (pointEquals(boundary.p1, otherEnd)) {
24
- return [{ ...boundary, p0: entry.projection }, ...lines.slice(1)];
23
+ if (pointEquals(boundary.p0, otherEnd)) {
24
+ return [...lines.slice(0, -1), { ...boundary, p1: entry.projection }];
25
25
  }
26
- return [{ ...entry.segment, p0: entry.projection, p1: chosenEndpoint }, ...lines];
26
+ return [...lines, { ...entry.segment, p0: chosenEndpoint, p1: entry.projection }];
27
27
  }
28
28
  /**
29
- * Mirror of {@link attachFromEntry} for the TO side: the TO boundary is the
30
- * last line and `boundary.p1` is its graph node.
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.
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[lines.length - 1];
38
- const chosenEndpoint = boundary.p1;
37
+ const boundary = lines[0];
38
+ const chosenEndpoint = boundary.p0;
39
39
  const otherEnd = pointEquals(entry.segment.p0, chosenEndpoint)
40
40
  ? entry.segment.p1
41
41
  : entry.segment.p0;
42
- if (pointEquals(boundary.p0, otherEnd)) {
43
- return [...lines.slice(0, -1), { ...boundary, p1: entry.projection }];
42
+ if (pointEquals(boundary.p1, otherEnd)) {
43
+ return [{ ...boundary, p0: entry.projection }, ...lines.slice(1)];
44
44
  }
45
- return [...lines, { ...entry.segment, p0: chosenEndpoint, p1: entry.projection }];
45
+ return [{ ...entry.segment, p0: entry.projection, p1: chosenEndpoint }, ...lines];
46
46
  }
47
47
  function attachOffGraphEntries(lines, fromEntry, toEntry) {
48
48
  let result = lines;
@@ -1,12 +1,9 @@
1
1
  /**
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).
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.
6
5
  */
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;
6
+ export declare const VIRTUAL_LINE_PENALTY = 10000;
10
7
  /**
11
8
  * Fallback line weight when `line.weight` is 0 (meaning "not set" in floorplan data,
12
9
  * not "free passage").
@@ -1,12 +1,9 @@
1
1
  /**
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).
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.
6
5
  */
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;
6
+ export const VIRTUAL_LINE_PENALTY = 10000;
10
7
  /**
11
8
  * Fallback line weight when `line.weight` is 0 (meaning "not set" in floorplan data,
12
9
  * not "free passage").
@@ -1,8 +1,6 @@
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.
6
4
  * @param points
7
5
  * @param lines
8
6
  */
@@ -6,8 +6,6 @@ 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.
11
9
  * @param points
12
10
  * @param lines
13
11
  */
@@ -1,11 +1,8 @@
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
- // `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;
5
+ export const arePointsClose = (p1, p2) => p1.layer === p2.layer && pointDistance(p1, p2) <= POINT_PROXIMITY_THRESHOLD;
9
6
  export const matchesLine = (line, p0, p1) => (arePointsClose(line.p0, p0) && arePointsClose(line.p1, p1)) ||
10
7
  (arePointsClose(line.p0, p1) && arePointsClose(line.p1, p0));
11
8
  export function findLineByEndpoints(lines, p0, p1) {
@@ -1,12 +1,6 @@
1
1
  import { type GraphLine } from '../types.js';
2
- export declare function changesLayer(line: GraphLine): boolean;
3
2
  /**
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`).
3
+ * Weighted cost of a route line for A* graph edge (legacy formula).
10
4
  * @param line
11
5
  */
12
6
  export declare function linkCost(line: GraphLine): number;
@@ -1,24 +1,11 @@
1
1
  import { pointDistance } from '@expofp/geometry';
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
- }
2
+ import { DEFAULT_LINE_WEIGHT, VIRTUAL_LINE_PENALTY } from './constants.js';
9
3
  /**
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`).
4
+ * Weighted cost of a route line for A* graph edge (legacy formula).
16
5
  * @param line
17
6
  */
18
7
  export function linkCost(line) {
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;
8
+ if (line.virtual)
9
+ return line.virtualLength ?? VIRTUAL_LINE_PENALTY;
10
+ return pointDistance(line.p0, line.p1) / (line.weight || DEFAULT_LINE_WEIGHT);
24
11
  }
@@ -41,9 +41,8 @@ export function createAStarPathFinder() {
41
41
  return !!graph.hasNode(id);
42
42
  },
43
43
  /**
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.
44
+ * Returns path nodes in **target→source** order (A* reconstructs from the goal);
45
+ * downstream consumers depend on this ordering.
47
46
  * @param fromId
48
47
  * @param toId
49
48
  */
@@ -53,12 +52,10 @@ export function createAStarPathFinder() {
53
52
  const nodes = finder.find(fromId, toId);
54
53
  if (!nodes.length)
55
54
  return [];
56
- return nodes
57
- .map((node) => {
55
+ return nodes.map((node) => {
58
56
  const [layer, x, y] = parseNodeId(node.id);
59
57
  return { id: node.id, layer, x, y };
60
- })
61
- .reverse();
58
+ });
62
59
  },
63
60
  };
64
61
  }
@@ -6,6 +6,7 @@ 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';
9
10
  export { computeBoundingBox } from './rendering/routeGeometry.js';
10
11
  export { getRouteLength } from './routing/getRouteLength.js';
11
12
  export type { GpsProjectionConfig } from './position/gpsThreshold.js';
@@ -3,6 +3,7 @@ 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';
6
7
  export { computeBoundingBox } from './rendering/routeGeometry.js';
7
8
  // Routing utilities
8
9
  export { getRouteLength } from './routing/getRouteLength.js';
@@ -1,9 +1,4 @@
1
1
  import { type RouteLine, type RoutePoint, type RouteSnapResult, type SnapToRouteConfig } from '../types.js';
2
2
  export { SNAP_THRESHOLD_METERS } from './gpsThreshold.js';
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;
3
+ export declare function snapToRoute(position: RoutePoint, routeLines: RouteLine[], config: SnapToRouteConfig): RouteSnapResult;
9
4
  //# sourceMappingURL=snapToRoute.d.ts.map
@@ -1,18 +1,38 @@
1
1
  import { pointDistance } from '@expofp/geometry';
2
+ import { pointInPolygon } from '../geometry/pointInPolygon.js';
2
3
  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
+ }
3
27
  export { SNAP_THRESHOLD_METERS } from './gpsThreshold.js';
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 }) {
28
+ export function snapToRoute(position, routeLines, config) {
10
29
  const lineCount = routeLines.length;
11
30
  if (!lineCount)
12
31
  return { snapped: false };
32
+ const { snapThreshold, to, from, minRemainingUnits = 0.5 } = config;
13
33
  const posLayer = position.layer || null;
14
34
  let bestDistance = Infinity;
15
- let best = null;
35
+ let bestResult = null;
16
36
  let accumulatedLength = 0;
17
37
  let hasSegmentOnPosLayer = posLayer == null;
18
38
  for (let i = 0; i < lineCount; i++) {
@@ -29,22 +49,66 @@ export function snapToRoute(position, routeLines, { snapThreshold }) {
29
49
  const projection = projectPointOnSegment({ x: position.x, y: position.y }, p0, p1);
30
50
  if (projection.distance < bestDistance) {
31
51
  bestDistance = projection.distance;
32
- best = {
33
- point: { layer: p0.layer, x: projection.projected.x, y: projection.projected.y },
34
- line,
35
- fromStart: accumulatedLength + segLen * projection.t,
52
+ bestResult = {
53
+ segmentIndex: i,
54
+ t: projection.t,
55
+ distanceFromPolylineStart: accumulatedLength + segLen * projection.t,
36
56
  };
37
57
  }
38
58
  accumulatedLength += segLen;
39
59
  }
40
60
  if (posLayer != null && !hasSegmentOnPosLayer)
41
61
  return { snapped: false };
42
- if (!best || bestDistance > snapThreshold)
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)
43
99
  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);
44
108
  return {
45
109
  snapped: true,
46
- snappedPoint: best.point,
47
- snappedLine: best.line,
48
- distance: accumulatedLength - best.fromStart,
110
+ snappedPoint: final.point,
111
+ snappedLine: final.line,
112
+ distance: remainingToDestination,
49
113
  };
50
114
  }
@@ -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
- // 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',
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',
98
98
  };
99
99
  });
100
100
  // 5. Deduplicate by coordinates. Drop entries that collide with an exit
@@ -28,8 +28,16 @@ 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
- // 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.
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();
33
41
  const allLines = segmentResults.flatMap((result) => result.lines);
34
42
  const totalDistance = segmentResults.reduce((sum, result) => sum + result.totalDistance, 0);
35
43
  return { lines: allLines, totalDistance };
@@ -1,3 +1,3 @@
1
1
  import { type RouteLine } from '../types.js';
2
- export declare function getRouteLength(routeLines: readonly RouteLine[]): number;
2
+ export declare function getRouteLength(routeLines: 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 link rather than a physical path segment. May or may not change layers. */
18
+ /** Virtual (floor-transition) link rather than a physical path segment. */
19
19
  readonly virtual: boolean;
20
20
  }
21
21
  /**
@@ -31,10 +31,11 @@ export interface GraphLine extends RouteLine {
31
31
  /** Link weight for cost (`0` = unset → `DEFAULT_LINE_WEIGHT`). */
32
32
  readonly weight: number;
33
33
  /**
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.
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.
38
39
  */
39
40
  readonly virtualLength?: number;
40
41
  }
@@ -98,5 +99,8 @@ export interface RouteEndpoint extends RoutePoint {
98
99
  }
99
100
  export interface SnapToRouteConfig {
100
101
  readonly snapThreshold: number;
102
+ readonly minRemainingUnits?: number;
103
+ readonly to: RouteEndpoint;
104
+ readonly from?: RouteEndpoint;
101
105
  }
102
106
  //# sourceMappingURL=types.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { Box } from '@expofp/geometry';
2
- import { createRerouteController, getRouteLength, } from '../core/index.js';
2
+ import { createRerouteController, getRouteLength, snapToRoute, } 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,7 +7,6 @@ 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';
11
10
  import { createTrailView } from './trailView.js';
12
11
  import { createTransitionView } from './transitionView.js';
13
12
  const linesToPoints = (lines) => lines.flatMap((line) => [line.p0, line.p1]);
@@ -85,6 +84,8 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
85
84
  const data = computeRouteRenderData({
86
85
  routeLines,
87
86
  currentRouteLayer: getCurrentRouteLayer(),
87
+ from: currentFrom,
88
+ to: currentTo,
88
89
  snap,
89
90
  });
90
91
  routeLinesView.draw(data, preserveProgress);
@@ -109,14 +110,12 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
109
110
  // `hasSnappedCurrentRoute` only flips for a live-CP FROM (booth/kiosk origins never snap),
110
111
  // so it already implies a CP origin — no need to re-check the endpoint id here.
111
112
  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.
114
113
  if (fromAnchor) {
115
- positionTrails.setTrail(route.routePoints[0], fromAnchor);
114
+ positionTrails.setTrail(route.routePoints[route.routePoints.length - 1], fromAnchor);
116
115
  return;
117
116
  }
118
117
  const toAnchor = pickAnchor(route.to);
119
- positionTrails.setTrail(toAnchor ? route.routePoints[route.routePoints.length - 1] : null, toAnchor);
118
+ positionTrails.setTrail(toAnchor ? route.routePoints[0] : null, toAnchor);
120
119
  }
121
120
  function syncRouteVisuals() {
122
121
  if (!currentFrom || !currentTo)
@@ -158,7 +157,11 @@ export function createWayfindingRuntime({ engine, renderer, iconProvider, floorC
158
157
  : // Selected floor doesn't pass through the route — view has nothing
159
158
  // to draw, but distance must still update. Snap against the full
160
159
  // non-virtual route directly.
161
- snapPositionToRoute(currentPosition, nonVirtual, currentFrom, currentTo, snapThreshold);
160
+ snapToRoute(currentPosition, nonVirtual, {
161
+ snapThreshold,
162
+ from: currentFrom,
163
+ to: currentTo,
164
+ });
162
165
  if (result.snapped)
163
166
  hasSnappedCurrentRoute = true;
164
167
  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[0];
14
- const destPosition = visibleRoutePoints[visibleRoutePoints.length - 1];
13
+ const sourcePosition = visibleRoutePoints[visibleRoutePoints.length - 1];
14
+ const destPosition = visibleRoutePoints[0];
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';
1
2
  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,7 +12,11 @@ export function createPositionTrailView({ renderer, iconProvider, floorContext,
12
12
  positionIcon.hide();
13
13
  },
14
14
  applyToRoute(position, route, routeLines) {
15
- const snap = snapPositionToRoute(position, routeLines, route.from, route.to, snapThreshold);
15
+ const snap = snapToRoute(position, [...routeLines], {
16
+ snapThreshold,
17
+ from: route.from,
18
+ to: route.to,
19
+ });
16
20
  const hidden = !floorContext.isLayerVisible(position.layer);
17
21
  if (snap.snapped) {
18
22
  positionIcon.place({ ...position, x: snap.snappedPoint.x, y: snap.snappedPoint.y }, hidden);
@@ -1,4 +1,4 @@
1
- import { type RouteLine, type RouteSnapResult } from '../core/index.js';
1
+ import { type RouteEndpoint, 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,19 +12,23 @@ 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 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.
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.
19
19
  * @param root0
20
20
  * @param root0.routeLines
21
21
  * @param root0.currentRouteLayer
22
+ * @param root0.from
23
+ * @param root0.to
22
24
  * @param root0.snap
23
25
  */
24
- export declare function computeRouteRenderData({ routeLines, currentRouteLayer, snap, }: {
26
+ export declare function computeRouteRenderData({ routeLines, currentRouteLayer, from, to, snap, }: {
25
27
  readonly routeLines: RouteLine[];
26
28
  /** null = show-all mode; otherwise the active route floor. */
27
29
  readonly currentRouteLayer: string | null;
30
+ readonly from: RouteEndpoint;
31
+ readonly to: RouteEndpoint;
28
32
  readonly snap: RouteSnapResult | null;
29
33
  }): RouteRenderData;
30
34
  export {};
@@ -1,20 +1,22 @@
1
- import { getRouteLength, splitRouteByPoint, } from '../core/index.js';
1
+ import { getRouteLength, normalizeRouteDirection, 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 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.
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.
11
11
  * @param root0
12
12
  * @param root0.routeLines
13
13
  * @param root0.currentRouteLayer
14
+ * @param root0.from
15
+ * @param root0.to
14
16
  * @param root0.snap
15
17
  */
16
- export function computeRouteRenderData({ routeLines, currentRouteLayer, snap, }) {
17
- const nonVirtualOrdered = routeLines.filter((line) => !line.virtual);
18
+ export function computeRouteRenderData({ routeLines, currentRouteLayer, from, to, snap, }) {
19
+ const nonVirtualOrdered = normalizeRouteDirection(routeLines.filter((line) => !line.virtual), from, to);
18
20
  const clipToVisibleFloor = (lines) => currentRouteLayer === null
19
21
  ? lines
20
22
  : lines.filter((line) => line.p0.layer === currentRouteLayer);
@@ -32,10 +34,7 @@ export function computeRouteRenderData({ routeLines, currentRouteLayer, snap, })
32
34
  // Ref-equal result means no segment matched the snap layer (bestIndex === -1); only on a
33
35
  // real split do we anchor the first remaining segment at the snap point.
34
36
  const splitOccurred = remaining !== nonVirtualOrdered;
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
37
+ const adjustedRemaining = splitOccurred && remaining.length > 0
39
38
  ? [{ ...remaining[0], p0: snap.snappedPoint }, ...remaining.slice(1)]
40
39
  : remaining;
41
40
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/wayfinding",
3
- "version": "3.11.11",
3
+ "version": "3.12.0",
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.11.11"
37
+ "@expofp/geometry": "3.12.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@expofp/renderer": "^3.2.4"
@@ -1,10 +0,0 @@
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
@@ -1,36 +0,0 @@
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
- }