@osm-editor-kit/osm-way-chain 0.1.0-alpha.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 ADDED
@@ -0,0 +1,39 @@
1
+ # `@osm-editor-kit/osm-way-chain`
2
+
3
+ **Status:** First npm **alpha** (`0.1.0-alpha.0`). Publish with `bun run packages:release -- --publish-only` after `npm login`.
4
+
5
+ ## What it does
6
+
7
+ Bidirectional OSM way-chain traversal for lane editors: walk connected highway ways from a center segment, score neighbor candidates (same kind, name, ref), surface ambiguous junctions, and orient geometry plus directional tags when a way is reversed.
8
+
9
+ Includes `mirrorTags` for flipping digitization direction (left/right, forward/backward, oneway, lane pipes, placement), road-like highway predicates (`public` vs `inclusive` inclusion style, Overpass selectors), and a session adapter over in-memory `ParsedOsmData` from `@osm-editor-kit/osm-data`.
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import type { ParsedOsmData } from '@osm-editor-kit/osm-data'
15
+ import {
16
+ buildChain,
17
+ createSessionGraphAdapter,
18
+ isEditableRoadLikeSegment,
19
+ overpassRoadLikeSelector,
20
+ } from '@osm-editor-kit/osm-way-chain'
21
+
22
+ const graph: ParsedOsmData = /* loaded viewport data */
23
+ const adapter = createSessionGraphAdapter(graph)
24
+
25
+ const { chain, pendingJunctions } = await buildChain(adapter, {
26
+ centerWayId: 123456,
27
+ maxPerSide: 5,
28
+ candidateFilter: (candidate, from) =>
29
+ isEditableRoadLikeSegment(candidate) && isEditableRoadLikeSegment(from),
30
+ })
31
+
32
+ // chain.segments: predecessors, center, successors (ordered)
33
+ // pendingJunctions: nodes where multiple equally good neighbors need a user pick
34
+
35
+ // Overpass fragment for lane-mode downloads:
36
+ overpassRoadLikeSelector('public')
37
+ ```
38
+
39
+ Implement `OsmDataAdapter` (`getWay`, `getWaysForNode`) for other data sources. Use `extendChainAtJunction` and `recenterChain` to continue after resolving a junction choice.
@@ -0,0 +1,23 @@
1
+ import type { LineString } from 'geojson';
2
+ export type OsmTags = Record<string, string>;
3
+ export type Segment = {
4
+ id: number;
5
+ version: number;
6
+ nodeIds: number[];
7
+ tags: OsmTags;
8
+ geometry: LineString;
9
+ /** True when geometry direction was flipped relative to chain traversal */
10
+ reversed?: boolean;
11
+ };
12
+ export type SegmentChain = {
13
+ /** Ordered segments: predecessors (left), center, successors (right) */
14
+ segments: Segment[];
15
+ /** Index of the reference/center segment */
16
+ centerIndex: number;
17
+ };
18
+ export type JunctionChoice = {
19
+ nodeId: number;
20
+ direction: 'backward' | 'forward';
21
+ candidates: Segment[];
22
+ };
23
+ export type NeighborDirection = 'backward' | 'forward';
@@ -0,0 +1,30 @@
1
+ import type { OsmTags } from '@osm-editor-kit/osm-data';
2
+ import type { Segment } from './domain/types';
3
+ /**
4
+ * Base highway values used for lane-mode street chain traversal.
5
+ * Link variants (`*_link`) and `highway=busway` are handled separately.
6
+ */
7
+ export declare const ROAD_LIKE_HIGHWAY_BASE_REGEX: RegExp;
8
+ /**
9
+ * How broadly the editor includes low-priority access roads in the map.
10
+ *
11
+ * - `public` (default): skip private access, driveways, emergency access, and parking aisles
12
+ * - `inclusive`: include those ways (previous editor behavior)
13
+ */
14
+ export type HighwayInclusionStyle = 'public' | 'inclusive';
15
+ export declare const DEFAULT_HIGHWAY_INCLUSION_STYLE: HighwayInclusionStyle;
16
+ export declare const OVERPASS_PUBLIC_ROAD_FILTERS = "[service!=emergency_access][service!=parking_aisle][service!=driveway][access!=private]";
17
+ /** Overpass selector for road-like ways under the given inclusion style. */
18
+ export declare function overpassRoadLikeSelector(style?: HighwayInclusionStyle): string;
19
+ /** Ways that are usually noise in street-space editing (private driveways, etc.). */
20
+ export declare function isClutterAccessWay(tags: OsmTags): boolean;
21
+ /** Whether a way should be shown/edited under the chosen inclusion style. */
22
+ export declare function matchesHighwayInclusionStyle(tags: OsmTags, style?: HighwayInclusionStyle): boolean;
23
+ /** Whether OSM tags describe a road-like highway suitable for lane-mode chaining. */
24
+ export declare function isRoadLikeHighway(tags: OsmTags): boolean;
25
+ /** Road-like highways that pass the active inclusion style. */
26
+ export declare function isEditableRoadLikeHighway(tags: OsmTags, style?: HighwayInclusionStyle): boolean;
27
+ /** Segment-level predicate for `buildChain` / `filterNeighborCandidates`. */
28
+ export declare function isRoadLikeSegment(segment: Segment): boolean;
29
+ /** Segment-level predicate that also applies the editor inclusion style. */
30
+ export declare function isEditableRoadLikeSegment(segment: Segment, style?: HighwayInclusionStyle): boolean;
@@ -0,0 +1,8 @@
1
+ export type { JunctionChoice, NeighborDirection, OsmTags, Segment, SegmentChain, } from './domain/types';
2
+ export { DEFAULT_HIGHWAY_INCLUSION_STYLE, isClutterAccessWay, isEditableRoadLikeHighway, isEditableRoadLikeSegment, isRoadLikeHighway, isRoadLikeSegment, matchesHighwayInclusionStyle, overpassRoadLikeSelector, ROAD_LIKE_HIGHWAY_BASE_REGEX, OVERPASS_PUBLIC_ROAD_FILTERS, type HighwayInclusionStyle, } from './highway-filter';
3
+ export type { OsmDataAdapter } from './ports/OsmDataAdapter';
4
+ export { createSessionGraphAdapter, osmWayToSegment } from './session-graph-adapter';
5
+ export { buildChain, extendChainAtJunction, getOtherEndpointNodeId, getSharedNodeBetween, recenterChain, type BuildChainOptions, type BuildChainResult, } from './traversal/buildChain';
6
+ export { isReversedAtNode, normalizeTagsForDirection, orientNeighbor, swapLeftRightKey, } from './traversal/direction';
7
+ export { mirrorTags } from './traversal/mirror-tags';
8
+ export { type CandidateFilter, filterNeighborCandidates, getEndpointNodeId, getKindKey, pickBestNeighbor, sameKind, scoreNeighborCandidate, } from './traversal/neighborMatch';
package/dist/index.js ADDED
@@ -0,0 +1,399 @@
1
+ // src/highway-filter.ts
2
+ var ROAD_LIKE_HIGHWAY_BASE_REGEX = /^motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service/;
3
+ var DEFAULT_HIGHWAY_INCLUSION_STYLE = "public";
4
+ var CLUTTER_SERVICE_VALUES = /* @__PURE__ */ new Set(["driveway", "emergency_access", "parking_aisle"]);
5
+ var OVERPASS_PUBLIC_ROAD_FILTERS = "[service!=emergency_access][service!=parking_aisle][service!=driveway][access!=private]";
6
+ function overpassRoadLikeSelector(style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
7
+ const base = 'highway~"^motorway|trunk|primary|secondary|tertiary|unclassified|residential|service|living_street"';
8
+ if (style === "inclusive") return base;
9
+ return base + OVERPASS_PUBLIC_ROAD_FILTERS;
10
+ }
11
+ function isClutterAccessWay(tags) {
12
+ if (tags.access === "private") return true;
13
+ const service = tags.service;
14
+ return service != null && CLUTTER_SERVICE_VALUES.has(service);
15
+ }
16
+ function matchesHighwayInclusionStyle(tags, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
17
+ if (style === "inclusive") return true;
18
+ return !isClutterAccessWay(tags);
19
+ }
20
+ function isRoadLikeHighway(tags) {
21
+ const highway = tags.highway;
22
+ if (!highway) return false;
23
+ if (highway === "busway") return true;
24
+ const base = highway.endsWith("_link") ? highway.slice(0, -"_link".length) : highway;
25
+ return ROAD_LIKE_HIGHWAY_BASE_REGEX.test(base);
26
+ }
27
+ function isEditableRoadLikeHighway(tags, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
28
+ return isRoadLikeHighway(tags) && matchesHighwayInclusionStyle(tags, style);
29
+ }
30
+ function isRoadLikeSegment(segment) {
31
+ return isRoadLikeHighway(segment.tags);
32
+ }
33
+ function isEditableRoadLikeSegment(segment, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
34
+ return isEditableRoadLikeHighway(segment.tags, style);
35
+ }
36
+
37
+ // src/session-graph-adapter.ts
38
+ function wayCoordinates(way, nodeCoords) {
39
+ return way.nodes.map((nodeId) => {
40
+ const coord = nodeCoords[nodeId];
41
+ if (!coord) return null;
42
+ return [coord[1], coord[0]];
43
+ }).filter((coord) => coord != null);
44
+ }
45
+ function osmWayToSegment(way, nodeCoords) {
46
+ return {
47
+ id: way.id,
48
+ version: way.version,
49
+ nodeIds: way.nodes,
50
+ tags: way.tags,
51
+ geometry: {
52
+ type: "LineString",
53
+ coordinates: wayCoordinates(way, nodeCoords)
54
+ }
55
+ };
56
+ }
57
+ function buildNodeWayIndex(graph) {
58
+ const index = /* @__PURE__ */ new Map();
59
+ for (const way of Object.values(graph.ways)) {
60
+ for (const nodeId of way.nodes) {
61
+ const existing = index.get(nodeId);
62
+ if (existing) {
63
+ existing.push(way.id);
64
+ } else {
65
+ index.set(nodeId, [way.id]);
66
+ }
67
+ }
68
+ }
69
+ return index;
70
+ }
71
+ function createSessionGraphAdapter(graph) {
72
+ const nodeWayIndex = buildNodeWayIndex(graph);
73
+ const segmentCache = /* @__PURE__ */ new Map();
74
+ function getSegment(wayId) {
75
+ const cached = segmentCache.get(wayId);
76
+ if (cached) return cached;
77
+ const way = graph.ways[wayId];
78
+ if (!way) throw new Error(`Way ${wayId} not found in session graph`);
79
+ const segment = osmWayToSegment(way, graph.nodeCoords);
80
+ segmentCache.set(wayId, segment);
81
+ return segment;
82
+ }
83
+ return {
84
+ getWay: (wayId) => Promise.resolve(getSegment(wayId)),
85
+ getWaysForNode: (nodeId) => {
86
+ const wayIds = nodeWayIndex.get(nodeId) ?? [];
87
+ return Promise.resolve(wayIds.map(getSegment));
88
+ }
89
+ };
90
+ }
91
+
92
+ // src/traversal/mirror-tags.ts
93
+ var LEFT_RIGHT_RE = /:(left|right)(:|$)/g;
94
+ var FORWARD_BACKWARD_RE = /:(forward|backward)(:|$)/g;
95
+ var START_END_RE = /:(start|end)(:|$)/g;
96
+ var ONEWAY_FORWARD = /* @__PURE__ */ new Set(["yes", "true", "1"]);
97
+ var ONEWAY_REVERSE = /* @__PURE__ */ new Set(["-1", "reverse"]);
98
+ var TURN_SWAP = {
99
+ left: "right",
100
+ right: "left",
101
+ slight_left: "slight_right",
102
+ slight_right: "slight_left",
103
+ sharp_left: "sharp_right",
104
+ sharp_right: "sharp_left",
105
+ merge_to_left: "merge_to_right",
106
+ merge_to_right: "merge_to_left"
107
+ };
108
+ var PLACEMENT_RE = /^(left_of|right_of|middle_of):(\d+)$/i;
109
+ function mirrorDirectionalKey(key) {
110
+ return key.replace(LEFT_RIGHT_RE, (_match, side, suffix) => {
111
+ const swapped = side === "left" ? "right" : "left";
112
+ return `:${swapped}${suffix}`;
113
+ }).replace(FORWARD_BACKWARD_RE, (_match, dir, suffix) => {
114
+ const swapped = dir === "forward" ? "backward" : "forward";
115
+ return `:${swapped}${suffix}`;
116
+ }).replace(START_END_RE, (_match, pos, suffix) => {
117
+ const swapped = pos === "start" ? "end" : "start";
118
+ return `:${swapped}${suffix}`;
119
+ });
120
+ }
121
+ function isOnewayKey(key) {
122
+ const lower = key.toLowerCase();
123
+ return lower === "oneway" || lower.startsWith("oneway:");
124
+ }
125
+ function mirrorOnewayValue(value) {
126
+ const lower = value.trim().toLowerCase();
127
+ if (ONEWAY_FORWARD.has(lower)) return "-1";
128
+ if (ONEWAY_REVERSE.has(lower)) return "yes";
129
+ return value;
130
+ }
131
+ function isLanesPipeKey(key) {
132
+ return key.toLowerCase().includes(":lanes");
133
+ }
134
+ function mirrorTurnPart(part) {
135
+ const lower = part.trim().toLowerCase();
136
+ return TURN_SWAP[lower] ?? part;
137
+ }
138
+ function mirrorTurnToken(token) {
139
+ return token.split(";").map((part) => mirrorTurnPart(part)).join(";");
140
+ }
141
+ function mirrorChangeToken(token) {
142
+ const lower = token.trim().toLowerCase();
143
+ if (lower === "not_left") return "not_right";
144
+ if (lower === "not_right") return "not_left";
145
+ return token;
146
+ }
147
+ function mirrorLanesPipeValue(key, value) {
148
+ const lowerKey = key.toLowerCase();
149
+ const tokens = value.split("|");
150
+ const reversed = [...tokens].reverse();
151
+ if (lowerKey.includes("turn:lanes")) {
152
+ return reversed.map(mirrorTurnToken).join("|");
153
+ }
154
+ if (lowerKey.includes("change:lanes")) {
155
+ return reversed.map(mirrorChangeToken).join("|");
156
+ }
157
+ return reversed.join("|");
158
+ }
159
+ function mirrorBareSideValue(value) {
160
+ const lower = value.trim().toLowerCase();
161
+ if (lower === "left") return "right";
162
+ if (lower === "right") return "left";
163
+ return value;
164
+ }
165
+ function parsePositiveInt(value) {
166
+ if (value == null || value === "") return void 0;
167
+ const n = Number.parseInt(value.trim(), 10);
168
+ return Number.isFinite(n) && n > 0 ? n : void 0;
169
+ }
170
+ function laneCountForPlacement(tags) {
171
+ const lanes = parsePositiveInt(tags.lanes);
172
+ if (lanes != null) return lanes;
173
+ const fwd = parsePositiveInt(tags["lanes:forward"]) ?? 0;
174
+ const back = parsePositiveInt(tags["lanes:backward"]) ?? 0;
175
+ const both = parsePositiveInt(tags["lanes:both_ways"]) ?? 0;
176
+ const sum = fwd + back + both;
177
+ return sum > 0 ? sum : 0;
178
+ }
179
+ function mirrorPlacementValue(value, laneCount) {
180
+ const trimmed = value.trim().toLowerCase();
181
+ if (trimmed === "transition") return value;
182
+ const match = PLACEMENT_RE.exec(trimmed);
183
+ if (!match) return value;
184
+ const kind = match[1].toLowerCase();
185
+ const lane = Number.parseInt(match[2], 10);
186
+ if (!Number.isInteger(lane) || lane < 1) return value;
187
+ const total = laneCount > 0 ? laneCount : lane;
188
+ const mirroredLane = total + 1 - lane;
189
+ if (kind === "left_of") return `right_of:${mirroredLane}`;
190
+ if (kind === "right_of") return `left_of:${mirroredLane}`;
191
+ if (kind === "middle_of") return `middle_of:${mirroredLane}`;
192
+ return value;
193
+ }
194
+ function isPlacementKey(key) {
195
+ const lower = key.toLowerCase();
196
+ return lower === "placement" || lower.startsWith("placement:");
197
+ }
198
+ function isBareSideKey(key) {
199
+ const lower = key.toLowerCase();
200
+ return lower === "sidewalk" || lower === "cycleway" || lower === "busway";
201
+ }
202
+ function mirrorTagValue(key, value, tags, laneCount) {
203
+ if (isOnewayKey(key)) return mirrorOnewayValue(value);
204
+ if (isPlacementKey(key)) return mirrorPlacementValue(value, laneCount);
205
+ if (isBareSideKey(key)) return mirrorBareSideValue(value);
206
+ if (isLanesPipeKey(key)) return mirrorLanesPipeValue(key, value);
207
+ return value;
208
+ }
209
+ function mirrorTags(tags) {
210
+ const laneCount = laneCountForPlacement(tags);
211
+ const mirrored = {};
212
+ for (const [key, value] of Object.entries(tags)) {
213
+ const mirroredKey = mirrorDirectionalKey(key);
214
+ mirrored[mirroredKey] = mirrorTagValue(key, value, tags, laneCount);
215
+ }
216
+ return mirrored;
217
+ }
218
+
219
+ // src/traversal/direction.ts
220
+ var LEFT_RIGHT_RE2 = /:(left|right)(:|$)/;
221
+ function isReversedAtNode(current, neighbor, sharedNodeId) {
222
+ const currentEnd = current.nodeIds[current.nodeIds.length - 1] === sharedNodeId ? "end" : "start";
223
+ const neighborEnd = neighbor.nodeIds[neighbor.nodeIds.length - 1] === sharedNodeId ? "end" : "start";
224
+ return currentEnd === neighborEnd;
225
+ }
226
+ function swapLeftRightKey(key) {
227
+ return key.replace(LEFT_RIGHT_RE2, (_match, side, suffix) => {
228
+ const swapped = side === "left" ? "right" : "left";
229
+ return `:${swapped}${suffix}`;
230
+ });
231
+ }
232
+ function normalizeTagsForDirection(tags, reversed) {
233
+ if (!reversed) return tags;
234
+ return mirrorTags(tags);
235
+ }
236
+ function orientNeighbor(neighbor, sharedNodeId, fromSegment) {
237
+ const reversed = isReversedAtNode(fromSegment, neighbor, sharedNodeId);
238
+ if (!reversed) return { ...neighbor, reversed: false };
239
+ const reversedNodeIds = [...neighbor.nodeIds].reverse();
240
+ const reversedCoords = [...neighbor.geometry.coordinates].reverse();
241
+ return {
242
+ ...neighbor,
243
+ nodeIds: reversedNodeIds,
244
+ geometry: {
245
+ type: "LineString",
246
+ coordinates: reversedCoords
247
+ },
248
+ tags: normalizeTagsForDirection(neighbor.tags, true),
249
+ reversed: true
250
+ };
251
+ }
252
+
253
+ // src/traversal/neighborMatch.ts
254
+ var KIND_KEYS = ["highway", "cycleway", "footway", "path", "railway", "waterway"];
255
+ function getKindKey(tags) {
256
+ for (const key of KIND_KEYS) {
257
+ if (tags[key]) return { key, value: tags[key] };
258
+ }
259
+ return void 0;
260
+ }
261
+ function sameKind(a, b) {
262
+ const kindA = getKindKey(a.tags);
263
+ const kindB = getKindKey(b.tags);
264
+ if (!kindA || !kindB) return false;
265
+ return kindA.key === kindB.key && kindA.value === kindB.value;
266
+ }
267
+ function getEndpointNodeId(segment, direction) {
268
+ const { nodeIds } = segment;
269
+ return direction === "forward" ? nodeIds[nodeIds.length - 1] : nodeIds[0];
270
+ }
271
+ function getOtherEndpointNodeId(segment, sharedNodeId) {
272
+ const first = segment.nodeIds[0];
273
+ const last = segment.nodeIds[segment.nodeIds.length - 1];
274
+ if (first === sharedNodeId) return last;
275
+ if (last === sharedNodeId) return first;
276
+ return void 0;
277
+ }
278
+ function filterNeighborCandidates(from, candidates, direction, candidateFilter) {
279
+ const endpoint = getEndpointNodeId(from, direction);
280
+ const touching = candidates.filter((c) => {
281
+ if (c.id === from.id) return false;
282
+ if (candidateFilter && !candidateFilter(c)) return false;
283
+ const first = c.nodeIds[0];
284
+ const last = c.nodeIds[c.nodeIds.length - 1];
285
+ return first === endpoint || last === endpoint;
286
+ });
287
+ const sameKindMatches = touching.filter((c) => sameKind(from, c));
288
+ if (sameKindMatches.length > 0) return sameKindMatches;
289
+ return touching;
290
+ }
291
+ function scoreNeighborCandidate(from, candidate) {
292
+ let score = 0;
293
+ if (sameKind(from, candidate)) score += 100;
294
+ if (candidate.tags.name && candidate.tags.name === from.tags.name) score += 50;
295
+ if (candidate.tags.ref && candidate.tags.ref === from.tags.ref) score += 30;
296
+ return score;
297
+ }
298
+ function pickBestNeighbor(from, candidates) {
299
+ if (candidates.length === 0) return void 0;
300
+ if (candidates.length === 1) return candidates[0];
301
+ const scored = candidates.map((c) => ({ segment: c, score: scoreNeighborCandidate(from, c) })).sort((a, b) => b.score - a.score);
302
+ const top = scored[0];
303
+ const second = scored[1];
304
+ if (top.score === second.score && top.score < 100) return void 0;
305
+ return top.segment;
306
+ }
307
+
308
+ // src/traversal/buildChain.ts
309
+ var DEFAULT_MAX_PER_SIDE = 5;
310
+ async function loadSegment(adapter, id, cache) {
311
+ const cached = cache.get(id);
312
+ if (cached) return cached;
313
+ const segment = await adapter.getWay(id);
314
+ cache.set(id, segment);
315
+ return segment;
316
+ }
317
+ async function extendChain(adapter, cache, start, direction, maxCount, candidateFilter) {
318
+ const extension = [];
319
+ const pendingJunctions = [];
320
+ let current = start;
321
+ for (let i = 0; i < maxCount; i++) {
322
+ const endpoint = getEndpointNodeId(current, direction);
323
+ const rawNeighbors = await adapter.getWaysForNode(endpoint);
324
+ const candidates = filterNeighborCandidates(current, rawNeighbors, direction, candidateFilter);
325
+ const best = pickBestNeighbor(current, candidates);
326
+ if (!best) {
327
+ if (candidates.length > 1) {
328
+ pendingJunctions.push({ nodeId: endpoint, direction, candidates });
329
+ }
330
+ break;
331
+ }
332
+ const fullBest = await loadSegment(adapter, best.id, cache);
333
+ const oriented = orientNeighbor(fullBest, endpoint, current);
334
+ extension.push(oriented);
335
+ current = oriented;
336
+ }
337
+ return { extension, pendingJunctions };
338
+ }
339
+ async function buildChain(adapter, options) {
340
+ const cache = new Map(options.knownSegments ?? []);
341
+ const maxPerSide = options.maxPerSide ?? DEFAULT_MAX_PER_SIDE;
342
+ const { candidateFilter } = options;
343
+ const center = await loadSegment(adapter, options.centerWayId, cache);
344
+ const [backwardResult, forwardResult] = await Promise.all([
345
+ extendChain(adapter, cache, center, "backward", maxPerSide, candidateFilter),
346
+ extendChain(adapter, cache, center, "forward", maxPerSide, candidateFilter)
347
+ ]);
348
+ const segments = [...backwardResult.extension.reverse(), center, ...forwardResult.extension];
349
+ return {
350
+ chain: {
351
+ segments,
352
+ centerIndex: backwardResult.extension.length
353
+ },
354
+ pendingJunctions: [...backwardResult.pendingJunctions, ...forwardResult.pendingJunctions]
355
+ };
356
+ }
357
+ async function extendChainAtJunction(adapter, chain, choice, selectedWayId, maxCount = DEFAULT_MAX_PER_SIDE, candidateFilter) {
358
+ const cache = new Map(chain.segments.map((s) => [s.id, s]));
359
+ const selected = cache.get(selectedWayId) ?? await adapter.getWay(selectedWayId);
360
+ cache.set(selected.id, selected);
361
+ const anchorIndex = choice.direction === "forward" ? chain.segments.length - 1 : 0;
362
+ const anchor = chain.segments[anchorIndex];
363
+ const oriented = orientNeighbor(selected, choice.nodeId, anchor);
364
+ const { extension, pendingJunctions } = await extendChain(
365
+ adapter,
366
+ cache,
367
+ oriented,
368
+ choice.direction,
369
+ maxCount,
370
+ candidateFilter
371
+ );
372
+ let segments;
373
+ if (choice.direction === "forward") {
374
+ segments = [...chain.segments, oriented, ...extension];
375
+ } else {
376
+ segments = [...extension.reverse(), oriented, ...chain.segments];
377
+ }
378
+ return {
379
+ chain: {
380
+ segments,
381
+ centerIndex: chain.centerIndex + (choice.direction === "backward" ? extension.length + 1 : 0)
382
+ },
383
+ pendingJunctions
384
+ };
385
+ }
386
+ function recenterChain(chain, newCenterWayId) {
387
+ const index = chain.segments.findIndex((s) => s.id === newCenterWayId);
388
+ if (index === -1) return chain;
389
+ return { segments: chain.segments, centerIndex: index };
390
+ }
391
+ function getSharedNodeBetween(a, b) {
392
+ const aEnds = /* @__PURE__ */ new Set([a.nodeIds[0], a.nodeIds[a.nodeIds.length - 1]]);
393
+ for (const nodeId of [b.nodeIds[0], b.nodeIds[b.nodeIds.length - 1]]) {
394
+ if (aEnds.has(nodeId)) return nodeId;
395
+ }
396
+ return void 0;
397
+ }
398
+
399
+ export { DEFAULT_HIGHWAY_INCLUSION_STYLE, OVERPASS_PUBLIC_ROAD_FILTERS, ROAD_LIKE_HIGHWAY_BASE_REGEX, buildChain, createSessionGraphAdapter, extendChainAtJunction, filterNeighborCandidates, getEndpointNodeId, getKindKey, getOtherEndpointNodeId, getSharedNodeBetween, isClutterAccessWay, isEditableRoadLikeHighway, isEditableRoadLikeSegment, isReversedAtNode, isRoadLikeHighway, isRoadLikeSegment, matchesHighwayInclusionStyle, mirrorTags, normalizeTagsForDirection, orientNeighbor, osmWayToSegment, overpassRoadLikeSelector, pickBestNeighbor, recenterChain, sameKind, scoreNeighborCandidate, swapLeftRightKey };
@@ -0,0 +1,5 @@
1
+ import type { Segment } from '../domain/types';
2
+ export type OsmDataAdapter = {
3
+ getWay: (wayId: number) => Promise<Segment>;
4
+ getWaysForNode: (nodeId: number) => Promise<Segment[]>;
5
+ };
@@ -0,0 +1,9 @@
1
+ import type { OsmWay, ParsedOsmData } from '@osm-editor-kit/osm-data';
2
+ import type { Segment } from './domain/types';
3
+ import type { OsmDataAdapter } from './ports/OsmDataAdapter';
4
+ export declare function osmWayToSegment(way: OsmWay, nodeCoords: ParsedOsmData['nodeCoords']): Segment;
5
+ /**
6
+ * Adapter backed by an already-loaded session graph (bbox coverage data).
7
+ * Lookups are synchronous but wrapped in Promises to satisfy `OsmDataAdapter`.
8
+ */
9
+ export declare function createSessionGraphAdapter(graph: ParsedOsmData): OsmDataAdapter;
@@ -0,0 +1,20 @@
1
+ import type { JunctionChoice, Segment, SegmentChain } from '../domain/types';
2
+ import type { OsmDataAdapter } from '../ports/OsmDataAdapter';
3
+ import { type CandidateFilter, getOtherEndpointNodeId } from './neighborMatch';
4
+ export type BuildChainOptions = {
5
+ centerWayId: number;
6
+ maxPerSide?: number;
7
+ /** Already loaded segments keyed by id */
8
+ knownSegments?: Map<number, Segment>;
9
+ /** Optional predicate to restrict neighbor candidates (e.g. road-like highways) */
10
+ candidateFilter?: CandidateFilter;
11
+ };
12
+ export type BuildChainResult = {
13
+ chain: SegmentChain;
14
+ pendingJunctions: JunctionChoice[];
15
+ };
16
+ export declare function buildChain(adapter: OsmDataAdapter, options: BuildChainOptions): Promise<BuildChainResult>;
17
+ export declare function extendChainAtJunction(adapter: OsmDataAdapter, chain: SegmentChain, choice: JunctionChoice, selectedWayId: number, maxCount?: number, candidateFilter?: CandidateFilter): Promise<BuildChainResult>;
18
+ export declare function recenterChain(chain: SegmentChain, newCenterWayId: number): SegmentChain;
19
+ export declare function getSharedNodeBetween(a: Segment, b: Segment): number | undefined;
20
+ export { getOtherEndpointNodeId };
@@ -0,0 +1,5 @@
1
+ import type { Segment } from '../domain/types';
2
+ export declare function isReversedAtNode(current: Segment, neighbor: Segment, sharedNodeId: number): boolean;
3
+ export declare function swapLeftRightKey(key: string): string;
4
+ export declare function normalizeTagsForDirection(tags: Segment['tags'], reversed: boolean): import("..").OsmTags;
5
+ export declare function orientNeighbor(neighbor: Segment, sharedNodeId: number, fromSegment: Segment): Segment;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Mirror OSM tags as if the way were digitised in the opposite direction.
3
+ * Applying twice returns the original tags (deep equality).
4
+ */
5
+ export declare function mirrorTags(tags: Record<string, string>): Record<string, string>;
@@ -0,0 +1,14 @@
1
+ import type { NeighborDirection, Segment } from '../domain/types';
2
+ export type CandidateFilter = (segment: Segment) => boolean;
3
+ export declare function getKindKey(tags: Segment['tags']): {
4
+ key: "cycleway" | "footway" | "highway" | "path" | "railway" | "waterway";
5
+ value: string;
6
+ } | undefined;
7
+ export declare function sameKind(a: Segment, b: Segment): boolean;
8
+ export declare function getEndpointNodeId(segment: Segment, direction: NeighborDirection): number;
9
+ export declare function getOtherEndpointNodeId(segment: Segment, sharedNodeId: number): number | undefined;
10
+ /** Filter candidates at a junction: exclude self, prefer same kind */
11
+ export declare function filterNeighborCandidates(from: Segment, candidates: Segment[], direction: NeighborDirection, candidateFilter?: CandidateFilter): Segment[];
12
+ /** Score candidate for auto-follow (higher = better) */
13
+ export declare function scoreNeighborCandidate(from: Segment, candidate: Segment): number;
14
+ export declare function pickBestNeighbor(from: Segment, candidates: Segment[]): Segment | undefined;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@osm-editor-kit/osm-way-chain",
3
+ "version": "0.1.0-alpha.0",
4
+ "description": "Traverse and orient connected OSM highway ways for multi-segment editing sessions.",
5
+ "license": "MIT",
6
+ "author": "Tobias Jordans",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/osmberlin/street-space-editor.git",
10
+ "directory": "packages/osm-way-chain"
11
+ },
12
+ "type": "module",
13
+ "sideEffects": false,
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "tag": "alpha"
27
+ },
28
+ "scripts": {
29
+ "build": "tsup && rm -f .tsbuildinfo && tsc -p tsconfig.build.json",
30
+ "type-check": "tsc --noEmit",
31
+ "test-run": "bun test src/test",
32
+ "prepublishOnly": "bun run build && bun ../../scripts/package-exports-for-publish.ts",
33
+ "postpublish": "bun ../../scripts/package-exports-restore.ts",
34
+ "prepack": "bun ../../scripts/package-exports-for-publish.ts",
35
+ "postpack": "bun ../../scripts/package-exports-restore.ts"
36
+ },
37
+ "dependencies": {
38
+ "@osm-editor-kit/osm-data": "workspace:*"
39
+ },
40
+ "devDependencies": {
41
+ "@types/geojson": "^7946.0.16",
42
+ "bun-types": "^1.2.20",
43
+ "tsup": "^8.5.1",
44
+ "typescript": "^7.0.0"
45
+ },
46
+ "publishExports": {
47
+ ".": {
48
+ "types": "./dist/index.d.ts",
49
+ "import": "./dist/index.js"
50
+ },
51
+ "./package.json": "./package.json"
52
+ }
53
+ }