@expofp/wayfinding 3.31.5 → 3.33.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.
package/README.md CHANGED
@@ -36,7 +36,7 @@ The package knows nothing concrete about the host renderer, graph data, or app
36
36
  state. The host supplies four ports via `WayfindingConfig`:
37
37
 
38
38
  - **`dataSource`** (`GraphDataSource`) — graph lines + line ends for pathfinding.
39
- - **`renderer`** (`RendererPort`) — def factories, current scale, scene layers, commit.
39
+ - **`renderer`** (`RendererPort`) — def factories, scene layers, commit.
40
40
  - **`iconProvider`** (`IconProvider`) — canvases for icon names.
41
41
  - **`floorContext`** (`FloorContext`) — active floor / visibility predicates.
42
42
 
@@ -72,9 +72,7 @@ wayfinding.setRoute({ from, to, accessible: false });
72
72
  wayfinding.setPosition(position); // or null to hide
73
73
  wayfinding.notifyFloorChanged();
74
74
 
75
- // Forward camera/pointer events:
76
- wayfinding.applyScale(scale);
77
- wayfinding.applyRoll(angle);
75
+ // Forward pointer events:
78
76
  wayfinding.handleClick(pickedDefs);
79
77
 
80
78
  wayfinding.destroy();
@@ -28,9 +28,9 @@ export interface WayfindingConfig {
28
28
  readonly onRouteDistance?: (distance: number) => void;
29
29
  }
30
30
  /**
31
- * The single wayfinding instance. Owns the routing engine, the scene renderer,
32
- * and the runtime; the host drives it with route/position/floor updates and
33
- * forwards its camera/pointer events to the `apply*`/`handle*` methods.
31
+ * The single wayfinding instance. Owns the routing engine, the scene renderer, and the runtime; the
32
+ * host drives it with route/position/floor updates and forwards its pointer events to the
33
+ * `handle*` methods. Camera moves need nothing from here — the defs carry their own layout rules.
34
34
  */
35
35
  export interface Wayfinding {
36
36
  /** Build (or update) the active route; `false` when no path exists. */
@@ -46,18 +46,16 @@ export interface Wayfinding {
46
46
  notifyFloorChanged(): void;
47
47
  /** Whether the position has drifted off the active route far enough to reroute. */
48
48
  shouldReroute(position: RoutePoint): boolean;
49
- /** Forward a camera-zoom change; rescales icons/trails and commits. */
50
- applyScale(scale: number): void;
51
- /** Forward a camera-roll change; re-orients cardinal-snap icons and commits. */
52
- applyRoll(angle: number): void;
53
- /** Forward a pointer click (already picked scene defs); fires icon `onClick`, and reports whether one claimed it. */
49
+ /**
50
+ * Forward a pointer click (already picked scene defs); fires icon `onClick`, and reports whether
51
+ * one claimed it.
52
+ */
54
53
  handleClick(defs: readonly RenderableDef[]): boolean;
55
54
  /** Forward a pointer move (already picked scene defs); `true` over a clickable icon. */
56
55
  handleHover(defs: readonly RenderableDef[]): boolean;
57
56
  /**
58
- * Place, update, or hide (`config: null`) a host annotation icon — e.g. kiosk
59
- * or you-are-here — on the wayfinding icon layer. These icons share the route
60
- * icons' scale/roll handling. Buffered — call `flush()` to commit.
57
+ * Place, update, or hide (`config: null`) a host annotation icon — kiosk, you-are-here — on the
58
+ * wayfinding icon layer, laid out like the route's own. Buffered — call `flush()` to commit.
61
59
  */
62
60
  setIcon(name: string, config: IconConfig | null, key?: string | number): void;
63
61
  /** Commit buffered scene mutations. */
@@ -22,10 +22,6 @@ export function createWayfinding(config) {
22
22
  onRouteUpdate: (lines, bounds) => config.onRouteUpdate?.(lines, bounds),
23
23
  onRouteDistance: (distance) => config.onRouteDistance?.(distance),
24
24
  });
25
- const renderDefs = (defs) => {
26
- if (defs.length)
27
- rendererPort.update(...defs);
28
- };
29
25
  return {
30
26
  setRoute: (input) => runtime.setRoute(input),
31
27
  buildRoute: (from, to, options) => engine.buildRoute(from, to, options),
@@ -33,11 +29,6 @@ export function createWayfinding(config) {
33
29
  setPosition: (position) => runtime.onPositionChanged(position),
34
30
  notifyFloorChanged: () => runtime.onFloorChanged(),
35
31
  shouldReroute: (position) => runtime.shouldReroute(position),
36
- applyScale: (scale) => renderDefs(renderer.applyScale(scale)),
37
- applyRoll: (angle) => renderDefs(renderer.applyRoll(angle)),
38
- // The renderer answers per def (it holds a def → icon map, so a pointer event
39
- // costs a lookup rather than a scan); the picked list is walked here, stopping
40
- // at the first icon that claims the click.
41
32
  handleClick: (defs) => {
42
33
  for (const def of defs) {
43
34
  if (renderer.handleClick(def))
@@ -1,15 +1,7 @@
1
1
  import type { RendererPort, WayfindingRenderer, WayfindingRendererConfig } from './types.js';
2
2
  /**
3
- * Create a {@link WayfindingRenderer} — the composition root that wires
4
- * together {@link LayerManager}, {@link IconManager}, {@link TrailManager},
5
- * and {@link RouteLineManager}.
6
- *
7
- * Exposes `handleClick`/`handleHover` for the app glue to drive from pointer events, and
8
- * `applyScale`/`applyRoll` for the deprecated imperative path, which has no handler dispatch of its
9
- * own (see {@link WayfindingRenderer.applyScale}).
10
- * @param port - The scene layers, def factories and commit function.
11
- * @param config - Optional color overrides.
12
- * @returns The renderer.
3
+ * Create a {@link WayfindingRenderer} — the composition root over {@link LayerManager},
4
+ * {@link IconManager}, {@link TrailManager} and {@link RouteLineManager}.
13
5
  */
14
6
  export declare function createWayfindingRenderer(port: RendererPort, config?: WayfindingRendererConfig): WayfindingRenderer;
15
7
  //# sourceMappingURL=createWayfindingRenderer.d.ts.map
@@ -8,16 +8,8 @@ const DEFAULT_COLORS = {
8
8
  active: '#0794EA',
9
9
  };
10
10
  /**
11
- * Create a {@link WayfindingRenderer} — the composition root that wires
12
- * together {@link LayerManager}, {@link IconManager}, {@link TrailManager},
13
- * and {@link RouteLineManager}.
14
- *
15
- * Exposes `handleClick`/`handleHover` for the app glue to drive from pointer events, and
16
- * `applyScale`/`applyRoll` for the deprecated imperative path, which has no handler dispatch of its
17
- * own (see {@link WayfindingRenderer.applyScale}).
18
- * @param port - The scene layers, def factories and commit function.
19
- * @param config - Optional color overrides.
20
- * @returns The renderer.
11
+ * Create a {@link WayfindingRenderer} — the composition root over {@link LayerManager},
12
+ * {@link IconManager}, {@link TrailManager} and {@link RouteLineManager}.
21
13
  */
22
14
  export function createWayfindingRenderer(port, config = {}) {
23
15
  const colors = { ...DEFAULT_COLORS, ...config.colors };
@@ -30,11 +22,6 @@ export function createWayfindingRenderer(port, config = {}) {
30
22
  clearIcons: (...args) => iconManager.clearIcons(...args),
31
23
  setTrail: (...args) => trailManager.setTrail(...args),
32
24
  setRouteLines: (...args) => routeLineManager.setRouteLines(...args),
33
- applyScale: (pixelSize) => [
34
- ...iconManager.applyScale(pixelSize),
35
- ...trailManager.applyScale(pixelSize),
36
- ],
37
- applyRoll: (cameraAngle) => iconManager.applyRoll(cameraAngle),
38
25
  handleClick: (def) => iconManager.handleClick(def),
39
26
  handleHover: (def) => iconManager.handleHover(def),
40
27
  clearRoute() {
@@ -2,28 +2,19 @@ import type { ImageDef, RenderableDef } from '@expofp/renderer';
2
2
  import type { LayerManager } from './layerManager.js';
3
3
  import type { IconConfig, RendererPort } from './types.js';
4
4
  /**
5
- * Lays one wayfinding icon out for a new pixel size: it holds its on-screen size, so its bounds are
6
- * the source's pixel dimensions times the pixel size, about its own centre — rotation and elevation
7
- * untouched.
5
+ * Lays one wayfinding icon out for a new pixel size: an icon holds its on-screen size, so its
6
+ * bounds are the source's pixel dimensions times the pixel size, about its own centre.
8
7
  *
9
- * The **only** writer of an icon's bounds size, which is what lets everything else here write logical
10
- * fields alone and never track a scale. One function for every icon in the plan, reached through
11
- * each icon's own `onScale` closure and called directly by `applyScale`.
12
- * @param def - The icon to lay out.
13
- * @param pixelSize - Plan units per device pixel.
14
- * @returns The def when it resized, nothing when the size was already right — a settled camera costs
15
- * no slot writes.
8
+ * The **only** writer of an icon's bounds size, which is what lets everything else here write
9
+ * logical fields alone and never track a scale. Returns nothing when the size was already right, so
10
+ * a settled camera costs no slot writes.
16
11
  */
17
12
  export declare function rescaleIcon(def: ImageDef, pixelSize: number): readonly RenderableDef[] | undefined;
18
13
  /**
19
14
  * Manages `ImageDef` icons in the scene graph.
20
15
  *
21
- * Icons are keyed by `(name, key?)`. First `setIcon` creates the `ImageDef`;
22
- * subsequent calls mutate in place. Pass `config: null` to hide an icon
23
- * without destroying it.
24
- *
25
- * Exposes pure `applyScale`/`applyRoll`/`handleClick`/`handleHover` that the
26
- * app glue drives from engine events.
16
+ * Icons are keyed by `(name, key?)`. First `setIcon` creates the `ImageDef`; later calls mutate in
17
+ * place. Pass `config: null` to hide an icon without destroying it.
27
18
  */
28
19
  export interface IconManager {
29
20
  /** Place, update, or hide an icon. See {@link WayfindingRenderer.setIcon}. */
@@ -34,21 +25,11 @@ export interface IconManager {
34
25
  hideAll(): void;
35
26
  /** Clear internal state. */
36
27
  destroy(): void;
37
- /** Lay every visible icon out for `pixelSize`; returns changed defs. See {@link rescaleIcon}. */
38
- applyScale(pixelSize: number): RenderableDef[];
39
- /** Apply camera roll to cardinal-snap icons; returns changed defs. */
40
- applyRoll(cameraAngle: number): RenderableDef[];
41
28
  /** Fire a clicked icon's `onClick`; returns whether the def was one. */
42
29
  handleClick(def: RenderableDef): boolean;
43
30
  /** Whether a def is a clickable icon. */
44
31
  handleHover(def: RenderableDef): boolean;
45
32
  }
46
- /**
47
- * Create an {@link IconManager} that places `ImageDef` icons into layers
48
- * managed by the given {@link LayerManager}.
49
- * @param layerManager - Where icons are placed and dirt is tracked.
50
- * @param port - Supplies the def factories; see {@link RendererPort}.
51
- * @returns The manager.
52
- */
33
+ /** Create an {@link IconManager} that places icons into layers the {@link LayerManager} tracks. */
53
34
  export declare function createIconManager(layerManager: LayerManager, port: RendererPort): IconManager;
54
35
  //# sourceMappingURL=iconManager.d.ts.map
@@ -1,16 +1,11 @@
1
1
  import { Rect } from '@expofp/geometry';
2
2
  /**
3
- * Lays one wayfinding icon out for a new pixel size: it holds its on-screen size, so its bounds are
4
- * the source's pixel dimensions times the pixel size, about its own centre — rotation and elevation
5
- * untouched.
3
+ * Lays one wayfinding icon out for a new pixel size: an icon holds its on-screen size, so its
4
+ * bounds are the source's pixel dimensions times the pixel size, about its own centre.
6
5
  *
7
- * The **only** writer of an icon's bounds size, which is what lets everything else here write logical
8
- * fields alone and never track a scale. One function for every icon in the plan, reached through
9
- * each icon's own `onScale` closure and called directly by `applyScale`.
10
- * @param def - The icon to lay out.
11
- * @param pixelSize - Plan units per device pixel.
12
- * @returns The def when it resized, nothing when the size was already right — a settled camera costs
13
- * no slot writes.
6
+ * The **only** writer of an icon's bounds size, which is what lets everything else here write
7
+ * logical fields alone and never track a scale. Returns nothing when the size was already right, so
8
+ * a settled camera costs no slot writes.
14
9
  */
15
10
  export function rescaleIcon(def, pixelSize) {
16
11
  const bounds = def.bounds;
@@ -22,21 +17,12 @@ export function rescaleIcon(def, pixelSize) {
22
17
  bounds.set(bounds.center.x, bounds.center.y, width, height, bounds.rotation, bounds.elevation);
23
18
  return [def];
24
19
  }
25
- /**
26
- * Create an {@link IconManager} that places `ImageDef` icons into layers
27
- * managed by the given {@link LayerManager}.
28
- * @param layerManager - Where icons are placed and dirt is tracked.
29
- * @param port - Supplies the def factories; see {@link RendererPort}.
30
- * @returns The manager.
31
- */
20
+ /** Create an {@link IconManager} that places icons into layers the {@link LayerManager} tracks. */
32
21
  export function createIconManager(layerManager, port) {
33
22
  const icons = new Map();
34
23
  const iconKeysByName = new Map();
35
24
  const keyToName = new Map();
36
- /**
37
- * The reverse of {@link icons}: a pointer event names the def that was hit, and this is what turns
38
- * it back into the icon that owns it — rather than scanning every record per event.
39
- */
25
+ /** The reverse of {@link icons}: a pointer hit names a def, so it is a lookup, not a scan. */
40
26
  const iconsByDef = new Map();
41
27
  function composeKey(name, key) {
42
28
  return key === undefined ? name : `${name}#${key}`;
@@ -68,26 +54,13 @@ export function createIconManager(layerManager, port) {
68
54
  hidden: cfg.hidden ?? false,
69
55
  dim: cfg.dimmed ? 1 : 0,
70
56
  origin: cfg.origin,
57
+ cardinalSnap: cfg.cardinalSnap,
71
58
  });
72
59
  def.onScale = ({ pixelSize }) => rescaleIcon(def, pixelSize);
73
- // Cardinal-snap icons also declare the roll rule, so a renderer that dispatches def handlers
74
- // keeps them square to the screen; `applyRoll` drives the same arithmetic for the path that
75
- // dispatches nothing.
76
- if (cfg.cardinalSnap) {
77
- def.onRoll = ({ roll }) => {
78
- const bounds = def.bounds;
79
- const delta = port.getRotation(roll, bounds.rotation);
80
- if (delta === undefined)
81
- return undefined;
82
- bounds.rotate(delta, bounds);
83
- return [def];
84
- };
85
- }
86
60
  layer.children.push(def);
87
61
  const record = {
88
62
  imageDef: def,
89
63
  layerName: cfg.layer,
90
- cardinalSnap: cfg.cardinalSnap ?? false,
91
64
  callback: cfg.onClick ?? null,
92
65
  };
93
66
  icons.set(iconKey, record);
@@ -118,19 +91,16 @@ export function createIconManager(layerManager, port) {
118
91
  // Logical fields only — where the icon is, which way it faces. The size it already carries is
119
92
  // correct for the camera it was laid out at, and moving an icon does not change that.
120
93
  const bounds = record.imageDef.bounds;
121
- bounds.set(cfg.x, cfg.y, bounds.size.x, bounds.size.y, cfg.rotation ?? 0, bounds.elevation);
94
+ const rotation = cfg.rotation ?? bounds.rotation;
95
+ bounds.set(cfg.x, cfg.y, bounds.size.x, bounds.size.y, rotation, bounds.elevation);
122
96
  record.imageDef.hidden = cfg.hidden ?? false;
123
97
  record.imageDef.dim = cfg.dimmed ? 1 : 0;
124
98
  record.callback = cfg.onClick ?? null;
125
99
  layerManager.touchDef(record.imageDef);
126
100
  }
127
101
  /**
128
- * Hides one icon and records that its def changed.
129
- *
130
- * The def, not the layer that holds it: the layer's children are unchanged, so a renderer
131
- * reconciling the layer would find nothing to do and never reach this instance. That is exactly
132
- * how a cleared route left its endpoint pins on screen.
133
- * @param record - The icon to hide.
102
+ * Hides one icon and records that its **def** changed, not the layer holding it: the layer's
103
+ * children are unchanged, so a renderer reconciling the layer would never reach this instance.
134
104
  */
135
105
  function hideIcon(record) {
136
106
  record.imageDef.hidden = true;
@@ -175,31 +145,6 @@ export function createIconManager(layerManager, port) {
175
145
  iconKeysByName.clear();
176
146
  keyToName.clear();
177
147
  },
178
- applyScale(pixelSize) {
179
- const defs = [];
180
- for (const record of icons.values()) {
181
- if (record.imageDef.hidden)
182
- continue;
183
- const changed = rescaleIcon(record.imageDef, pixelSize);
184
- if (changed)
185
- defs.push(...changed);
186
- }
187
- return defs;
188
- },
189
- applyRoll(cameraAngle) {
190
- const defs = [];
191
- for (const record of icons.values()) {
192
- if (!record.cardinalSnap)
193
- continue;
194
- const bounds = record.imageDef.bounds;
195
- const delta = port.getRotation(cameraAngle, bounds.rotation);
196
- if (delta === undefined)
197
- continue;
198
- bounds.rotate(delta, bounds);
199
- defs.push(record.imageDef);
200
- }
201
- return defs;
202
- },
203
148
  handleClick(def) {
204
149
  const record = iconsByDef.get(def);
205
150
  if (!record?.callback || record.imageDef.hidden)
@@ -1,5 +1,4 @@
1
1
  import { type Point2Like } from '@expofp/geometry';
2
- import type { RenderableDef } from '@expofp/renderer';
3
2
  import type { LayerManager } from './layerManager.js';
4
3
  import type { RendererPort } from './types.js';
5
4
  /**
@@ -19,8 +18,6 @@ export interface TrailManager {
19
18
  readonly canvas: HTMLCanvasElement;
20
19
  readonly layer: string;
21
20
  }): void;
22
- /** Rebuild all trails for `pixelSize`; returns changed layer defs. */
23
- applyScale(pixelSize: number): RenderableDef[];
24
21
  /** Remove all trails. */
25
22
  destroy(): void;
26
23
  }
@@ -20,57 +20,137 @@ export function createTrailManager(layerManager, port) {
20
20
  */
21
21
  let currentPixelSize = 1;
22
22
  /**
23
- * Rebuilds every trail on one layer for a new pixel size — what each trail layer's `onScale`
23
+ * The layers this manager has declared an `onScale` on.
24
+ *
25
+ * Declaring the handler is not enough to be called: `useLayoutTargets` registers a layer as a
26
+ * layout target from a render, so the layer has to be committed once after the handler appears.
27
+ * That commit is a membership change, which is why it happens once per layer rather than per
28
+ * redraw.
29
+ */
30
+ const wiredLayers = new Set();
31
+ /**
32
+ * Lays every trail on one layer out for a new pixel size — what each trail layer's `onScale`
24
33
  * closure delegates to.
25
34
  *
26
35
  * One function per manager, not per def: what it reads is this manager's trails, and the closure
27
36
  * hands it the one layer it serves.
28
37
  * @param layer - The trail layer.
29
38
  * @param pixelSize - Plan units per device pixel.
30
- * @returns The layer when its membership changed, so the renderer reconciles it.
39
+ * @returns The dots that were written, plus the layer when the dot count changed with them.
31
40
  */
32
41
  function layOutTrails(layer, pixelSize) {
33
42
  currentPixelSize = pixelSize;
34
- let rebuilt = false;
43
+ const changed = [];
44
+ let membershipChanged = false;
35
45
  for (const record of trails.values()) {
36
46
  if (record.layerName !== layer.name)
37
47
  continue;
38
- redraw(record);
39
- rebuilt = true;
48
+ const result = redraw(record);
49
+ changed.push(...result.changed);
50
+ membershipChanged ||= result.membershipChanged;
40
51
  }
41
- if (rebuilt)
42
- return [layer];
43
- return undefined;
52
+ // The layer only when the dot count changed: a def write lands in this frame, a membership
53
+ // change a transition later, so a camera step that only shifts dots stays in this one.
54
+ if (membershipChanged)
55
+ changed.push(layer);
56
+ return changed.length ? changed : undefined;
44
57
  }
45
- function removeDotsFromLayer(record) {
46
- if (!record.dotDefs.length)
58
+ /**
59
+ * Takes dots out of their layer, hidden first.
60
+ *
61
+ * The hide is not redundant with the removal: the renderer commits a def write synchronously and
62
+ * defers the membership sweep, so a dot that was only removed stays on screen a frame or more.
63
+ * @param record - The trail the dots belong to.
64
+ * @param dots - The dots to take out.
65
+ */
66
+ function removeDots(record, dots) {
67
+ if (!dots.length)
47
68
  return;
48
69
  const layer = layerManager.touchLayer(record.layerName);
49
- const oldDots = new Set(record.dotDefs);
50
- layer.children = layer.children.filter((child) => !oldDots.has(child));
51
- record.dotDefs = [];
70
+ const dropped = new Set(dots);
71
+ for (const dot of dropped)
72
+ dot.hidden = true;
73
+ layer.children = layer.children.filter((child) => !dropped.has(child));
52
74
  }
53
- function redraw(record) {
54
- const layer = layerManager.touchLayer(record.layerName);
55
- // Declared here rather than at construction, because this is where the layer is first known. The
56
- // `update(layer)` that a flush or a re-layout performs is what makes the layer register under it.
75
+ /**
76
+ * The trail layer, with this manager's `onScale` declared on it the first time it is asked for.
77
+ * @param name - The layer's name.
78
+ * @returns The layer.
79
+ */
80
+ function wireLayer(name) {
81
+ const layer = layerManager.resolveLayer(name);
82
+ if (wiredLayers.has(name))
83
+ return layer;
57
84
  layer.onScale = ({ pixelSize }) => layOutTrails(layer, pixelSize);
85
+ wiredLayers.add(name);
86
+ // Committed with the layer, which is what makes the renderer register the handler.
87
+ layerManager.touchLayer(name);
88
+ return layer;
89
+ }
90
+ /**
91
+ * Lays one trail out at the current pixel size, reusing the dots it already holds.
92
+ *
93
+ * Spacing is fixed in screen pixels, so a camera step moves every dot and changes how many there
94
+ * are. Moving the survivors rather than replacing them is what keeps the common case a def write:
95
+ * only the difference in count reaches the layer's membership.
96
+ * @param record - The trail to lay out.
97
+ * @returns The dots that were written, and whether the layer's membership changed with them.
98
+ */
99
+ function redraw(record) {
100
+ const layer = wireLayer(record.layerName);
58
101
  const interval = TRAIL_INTERVAL_AT_BASE_PIXEL_SIZE * currentPixelSize;
59
102
  const points = computeTrailPoints(record.from, record.to, interval);
60
103
  const canvas = record.canvas;
61
104
  const size = { x: canvas.width * currentPixelSize, y: canvas.height * currentPixelSize };
62
- const newDots = points.map((point) => port.createImageDef(canvas, new Rect({ x: point.x, y: point.y }, size)));
63
- removeDotsFromLayer(record);
64
- layer.children.push(...newDots);
65
- record.dotDefs = newDots;
105
+ const dots = record.dotDefs;
106
+ const kept = Math.min(dots.length, points.length);
107
+ const changed = [];
108
+ for (let i = 0; i < kept; i++) {
109
+ const bounds = dots[i].bounds;
110
+ bounds.set(points[i].x, points[i].y, size.x, size.y, bounds.rotation, bounds.elevation);
111
+ changed.push(dots[i]);
112
+ }
113
+ if (points.length > kept) {
114
+ const added = points
115
+ .slice(kept)
116
+ .map((point) => port.createImageDef(canvas, new Rect({ x: point.x, y: point.y }, size)));
117
+ layer.children.push(...added);
118
+ dots.push(...added);
119
+ layerManager.touchLayer(record.layerName);
120
+ return { changed, membershipChanged: true };
121
+ }
122
+ if (dots.length > kept) {
123
+ const dropped = dots.splice(kept);
124
+ removeDots(record, dropped);
125
+ // Hidden, and reported with the rest: the hide is a def write, and only a def the handler
126
+ // returns is written in this frame — the removal itself lands a transition later.
127
+ changed.push(...dropped);
128
+ return { changed, membershipChanged: true };
129
+ }
130
+ return { changed, membershipChanged: false };
66
131
  }
67
132
  function drop(name) {
68
133
  const record = trails.get(name);
69
134
  if (!record)
70
135
  return;
71
- removeDotsFromLayer(record);
136
+ removeDots(record, record.dotDefs);
137
+ for (const dot of record.dotDefs)
138
+ layerManager.touchDef(dot);
139
+ record.dotDefs = [];
72
140
  trails.delete(name);
73
141
  }
142
+ /**
143
+ * Queues what a redraw wrote for the next {@link LayerManager.flush} — the buffered path only.
144
+ *
145
+ * A redraw driven by the camera reports the same defs to the renderer, which writes them in that
146
+ * frame; queuing them here as well would leave them in the dirty set for an unrelated flush to
147
+ * commit again.
148
+ * @param result - What the redraw changed.
149
+ */
150
+ function bufferRedraw(result) {
151
+ for (const def of result.changed)
152
+ layerManager.touchDef(def);
153
+ }
74
154
  return {
75
155
  setTrail(name, from, to, options) {
76
156
  if (from === null || to === null) {
@@ -86,7 +166,7 @@ export function createTrailManager(layerManager, port) {
86
166
  }
87
167
  existing.from = from;
88
168
  existing.to = to;
89
- redraw(existing);
169
+ bufferRedraw(redraw(existing));
90
170
  return;
91
171
  }
92
172
  const record = {
@@ -97,20 +177,16 @@ export function createTrailManager(layerManager, port) {
97
177
  dotDefs: [],
98
178
  };
99
179
  trails.set(name, record);
100
- redraw(record);
101
- },
102
- applyScale(pixelSize) {
103
- currentPixelSize = pixelSize;
104
- const layers = new Set();
105
- for (const record of trails.values()) {
106
- redraw(record);
107
- layers.add(layerManager.touchLayer(record.layerName));
108
- }
109
- return [...layers];
180
+ bufferRedraw(redraw(record));
110
181
  },
111
182
  destroy() {
112
183
  for (const trailName of [...trails.keys()])
113
184
  drop(trailName);
185
+ // The layer outlives this manager, so the handler has to go with it: left in place it keeps
186
+ // the manager, its layers and its canvases reachable, and runs on every camera step.
187
+ for (const name of wiredLayers)
188
+ layerManager.resolveLayer(name).onScale = undefined;
189
+ wiredLayers.clear();
114
190
  },
115
191
  };
116
192
  }
@@ -1,54 +1,34 @@
1
1
  import type { Point2Like, Rect } from '@expofp/geometry';
2
2
  import type { ImageDef, ImageSource, LayerDef, LineDef, RenderableDef } from '@expofp/renderer';
3
3
  /**
4
- * What the wayfinding renderer needs from whatever is drawing the scene — the whole of it.
4
+ * What the wayfinding renderer needs from whatever is drawing the scene.
5
5
  *
6
6
  * The def factories are injected rather than imported because they live in the host
7
7
  * (floorplan's `src/renderer`), which depends on this package — importing them back would close a
8
- * cycle. What the port no longer carries is the camera: sizes are pushed in by the layout handlers
9
- * the defs declare (`onScale`), never pulled through a `getScale()`, and a geometry `Rect` is the
10
- * bounds type, so nothing needs converting on the way in either.
8
+ * cycle.
11
9
  */
12
10
  export interface RendererPort {
13
- /**
14
- * Builds an image def. Its bounds are laid out for the camera later, by `onScale`.
15
- * @param source - The canvas or image to draw.
16
- * @param bounds - Where it sits; size is a placeholder until the first layout.
17
- * @param options - Initial visibility, dimming and anchor.
18
- * @returns The def.
19
- */
11
+ /** Builds an image def. `bounds` carries a placeholder size until the first `onScale`. */
20
12
  createImageDef(source: ImageSource, bounds: Rect, options?: {
21
13
  hidden?: boolean;
22
14
  dim?: number;
23
15
  interactive?: boolean;
24
16
  origin?: [number, number];
17
+ /** Forwarded from {@link IconConfig.cardinalSnap}; the host attaches the rule. */
18
+ cardinalSnap?: boolean;
25
19
  }): ImageDef;
26
- /**
27
- * Builds a line def between two points.
28
- * @param line - Its endpoints.
29
- * @param color - Stroke color.
30
- * @param width - Stroke width.
31
- * @returns The def.
32
- */
20
+ /** Builds a line def between two points. */
33
21
  createLineDef(line: {
34
22
  p0: Point2Like;
35
23
  p1: Point2Like;
36
24
  }, color?: string, width?: number): LineDef;
37
- /**
38
- * The cardinal-snap delta for a camera angle.
39
- * @param newAngle - The camera's angle.
40
- * @param currentRotation - The def's current rotation.
41
- * @returns The delta to apply, or nothing while the camera is under the 45° threshold.
42
- */
43
- getRotation(newAngle: number, currentRotation: number): number | undefined;
44
25
  /** The scene's root layers; the `wf-*` layers are resolved out of these by name. */
45
26
  readonly layers: readonly LayerDef[];
46
27
  /**
47
28
  * Commits changed defs.
48
29
  *
49
- * Injected rather than imported for one reason: the deprecated imperative path must reach an
50
- * imperative renderer as well as renderer's `update`, and only its caller knows which.
51
- * @param defs - The defs that changed.
30
+ * Injected rather than imported: `@expofp/renderer` is a type-only peer here, so the concrete
31
+ * `update` comes from the host.
52
32
  */
53
33
  update(...defs: RenderableDef[]): void;
54
34
  }
@@ -80,13 +60,9 @@ export interface IconConfig {
80
60
  */
81
61
  readonly origin?: [number, number];
82
62
  /**
83
- * When true, renderer registers a camera-roll handler that snaps the icon
84
- * rotation to the nearest cardinal direction (N/S/E/W, 90° steps) as the
85
- * camera rotates past 45° thresholds. Used for label-like icons
86
- * (source/destination/transition) that should remain readable.
87
- *
88
- * Do NOT enable for icons whose `rotation` carries meaning (e.g. position
89
- * arrow heading, kiosk heading) — the snap would override that meaning.
63
+ * Keeps the icon readable as the camera rolls; the host's `createImageDef` attaches the rule.
64
+ * The turn is relative — the icon holds its starting heading plus whole quarters — so do NOT
65
+ * enable it where `rotation` carries meaning (position arrow, kiosk heading).
90
66
  *
91
67
  * Set at first setIcon for an icon key; ignored on subsequent updates.
92
68
  */
@@ -138,29 +114,11 @@ export interface WayfindingRenderer {
138
114
  readonly resetAnimation?: boolean;
139
115
  }): void;
140
116
  /**
141
- * Lays every icon and trail out for `pixelSize`, returning the defs that changed.
142
- *
143
- * **Not the mechanism** — the defs declare their own `onScale` and a renderer that dispatches them
144
- * needs nothing from here. This runs those same handlers in a loop, for the deprecated imperative
145
- * path, which has neither dispatch nor the "laid out before first drawn" guarantee registration
146
- * gives.
147
- * @param pixelSize - Plan units per device pixel.
148
- * @returns The defs that changed.
149
- */
150
- applyScale(pixelSize: number): RenderableDef[];
151
- /** Apply camera roll to cardinal-snap icons; returns changed defs to render. */
152
- applyRoll(cameraAngle: number): RenderableDef[];
153
- /**
154
- * Fires the def's `onClick`, if it is a clickable icon.
155
- * @param def - The def that was clicked.
156
- * @returns Whether it was one — so a caller can stop the click going any further.
117
+ * Fires the def's `onClick`; reports whether it was a clickable icon, so a caller can stop the
118
+ * click going any further.
157
119
  */
158
120
  handleClick(def: RenderableDef): boolean;
159
- /**
160
- * Whether a def is a clickable icon, which is what a pointer cursor hangs off.
161
- * @param def - The def under the pointer.
162
- * @returns Whether it is clickable.
163
- */
121
+ /** Whether a def is a clickable icon, which is what a pointer cursor hangs off. */
164
122
  handleHover(def: RenderableDef): boolean;
165
123
  /** Clear route lines, drop trails, hide icons. Buffered — `flush()` to commit. */
166
124
  clearRoute(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/wayfinding",
3
- "version": "3.31.5",
3
+ "version": "3.33.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,12 +34,12 @@
34
34
  "dependencies": {
35
35
  "ngraph.graph": "^19.1.0",
36
36
  "tslib": "^2.3.0",
37
- "@expofp/geometry": "3.31.5"
37
+ "@expofp/geometry": "3.33.0"
38
38
  },
39
39
  "peerDependencies": {
40
- "@expofp/renderer": "3.31.5"
40
+ "@expofp/renderer": "3.33.0"
41
41
  },
42
42
  "devDependencies": {
43
- "@expofp/renderer": "3.31.5"
43
+ "@expofp/renderer": "3.33.0"
44
44
  }
45
45
  }