@osm-editor-kit/osm-way-chain 0.1.0-alpha.4 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,26 @@
1
1
  # `@osm-editor-kit/osm-way-chain`
2
2
 
3
+ Walk connected OSM ways from a center way and orient them into one chain, for editors that change several segments of a street at once.
4
+
3
5
  > [!NOTE]
4
- > This package is an **alpha** release and still under active development. APIs may change; install with the npm `alpha` dist-tag.
6
+ > Claude Opus 5.5 wrote and reviewed the code of this package. [@tordans](https://github.com/tordans) specified what it should do and tested and verified its user-facing behavior.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npm install @osm-editor-kit/osm-way-chain
12
+ ```
13
+
14
+ It depends on `@osm-editor-kit/osm-data`, which npm installs for you.
5
15
 
6
16
  ## What it does
7
17
 
8
- - Walk connected highway ways left and right from a center segment
9
- - Rank neighbor candidates (same road kind, name, ref)
10
- - Flag junctions where more than one neighbor is equally good
11
- - Flip geometry and directional tags when a way is digitized the “wrong” way
12
- - Compile and match **app-owned** way-selection policies (Overpass + in-memory tags)
13
- - Adapter for in-memory session data from `@osm-editor-kit/osm-data`
18
+ - Walks connected ways left and right from a center way
19
+ - Ranks neighbor candidates (same kind of road, name, ref)
20
+ - Reports junctions where more than one neighbor is equally good, so the user can choose
21
+ - Flips geometry and directional tags when a way is digitized the "wrong" way (`mirrorTags`)
22
+ - Compiles app-owned way-selection policies to Overpass queries and matches them against in-memory tags
23
+ - Reads from an in-memory `ParsedOsmData` session (`createSessionGraphAdapter`) or any data source that implements `OsmDataAdapter`
14
24
 
15
25
  ## Usage
16
26
 
@@ -25,7 +35,7 @@ import {
25
35
  type OsmWaySelectionPolicy,
26
36
  } from '@osm-editor-kit/osm-way-chain'
27
37
 
28
- /** App-owned contract — which ways to download / chain. Not package defaults. */
38
+ // App-owned contract: which ways to download and chain. The package has no defaults.
29
39
  const wayPolicy: OsmWaySelectionPolicy = {
30
40
  include: [
31
41
  { all: [tag.oneOf('highway', ['residential', 'cycleway', 'path', 'footway', 'steps'])] },
@@ -39,15 +49,53 @@ const adapter = createSessionGraphAdapter(graph)
39
49
  const { chain, pendingJunctions } = await buildChain(adapter, {
40
50
  centerWayId: 123456,
41
51
  maxPerSide: 5,
42
- candidateFilter: (candidate, from) =>
43
- matchesOsmWaySelectionSegment(candidate, wayPolicy) &&
44
- matchesOsmWaySelectionSegment(from, wayPolicy),
52
+ candidateFilter: (candidate) => matchesOsmWaySelectionSegment(candidate, wayPolicy),
45
53
  })
46
54
 
47
- // Overpass download for the same policy:
48
- buildWaysOverpassQuery(wayPolicy, '52.5,13.4,52.51,13.41')
55
+ // Overpass query that downloads the same ways for a bbox (south,west,north,east):
56
+ const query = buildWaysOverpassQuery(wayPolicy, '52.5,13.4,52.51,13.41')
57
+ ```
58
+
59
+ `buildChain` returns the ordered `chain.segments`, the index of the center way in `chain.centerIndex`, and `pendingJunctions` for places where it could not pick a neighbor.
60
+
61
+ ## Junctions
62
+
63
+ When a junction has several equally good neighbors, `buildChain` stops there and lists them in `pendingJunctions`. Let the user pick one, then continue with `extendChainAtJunction(adapter, chain, choice, selectedWayId, { maxPerSide, candidateFilter })`. Use `recenterChain(chain, wayId)` to move the center to another way in the chain without reloading; it returns the chain unchanged when the way is not part of it.
64
+
65
+ ## Way-selection policies
66
+
67
+ A policy is a list of `include` clauses (OR), each a list of tag predicates (AND), plus optional `globalAll` predicates added to every clause. Build predicates with `tag.eq`, `tag.neq`, `tag.oneOf`, `tag.noneOf`, `tag.regex`, `tag.nregex`, `tag.present` and `tag.absent`. `compileOverpassWaySelectors` returns one Overpass filter per clause, `matchesOsmWaySelection` tests in-memory tags. A missing tag matches `neq`, `noneOf`, `nregex` and `absent`, as in Overpass.
68
+
69
+ `tag.regex` and `tag.nregex` patterns run as JavaScript `RegExp` in memory but go to Overpass unchanged, where they are POSIX extended regular expressions. Use only syntax both understand: anchors, groups, `|`, `[...]`, `*`, `+`, `?`, `{n,m}`. `\d`, `(?:...)`, lookarounds and lazy quantifiers behave differently or fail in Overpass.
70
+
71
+ ## Helpers
72
+
73
+ - `mirrorTags(tags)` returns the tags as if the way were digitized the other way round (left/right, forward/backward, `oneway`, `*:lanes`, `placement`). `normalizeTagsForDirection(tags, reversed)` applies it only when `reversed`; `swapLeftRightKey(key)` swaps one key.
74
+ - `orientNeighbor(neighbor, sharedNodeId, from)` flips a neighbor so it runs in the direction of `from`.
75
+ - `getSharedNodeBetween(a, b)`, `getEndpointNodeId(segment, direction)` and `getOtherEndpointNodeId(segment, nodeId)` read way endpoints.
76
+ - `osmWayToSegment(way, nodeCoords)` turns an `OsmWay` from `@osm-editor-kit/osm-data` into a `Segment`.
77
+
78
+ ## Other data sources
79
+
80
+ Implement `OsmDataAdapter` to chain ways from somewhere other than a loaded session:
81
+
82
+ ```ts
83
+ import type { OsmDataAdapter, Segment } from '@osm-editor-kit/osm-way-chain'
84
+
85
+ const waysById = new Map<number, Segment>() // fill from your own source
86
+
87
+ const adapter: OsmDataAdapter = {
88
+ getWay: async (wayId) => {
89
+ const way = waysById.get(wayId)
90
+ if (!way) throw new Error(`Way ${wayId} not found`)
91
+ return way
92
+ },
93
+ // All ways that use this node
94
+ getWaysForNode: async (nodeId) =>
95
+ [...waysById.values()].filter((way) => way.nodeIds.includes(nodeId)),
96
+ }
49
97
  ```
50
98
 
51
- Implement `OsmDataAdapter` (`getWay`, `getWaysForNode`) for other data sources. Use `extendChainAtJunction` and `recenterChain` to continue after resolving a junction choice.
99
+ ## License
52
100
 
53
- Legacy `overpassRoadLikeSelector` / `HighwayInclusionStyle` helpers remain as deprecated shims for street-space-editor.
101
+ MIT
@@ -1,23 +1,26 @@
1
+ import type { OsmTags } from '@osm-editor-kit/osm-data';
1
2
  import type { LineString } from 'geojson';
2
- export type OsmTags = Record<string, string>;
3
+ /** Walking direction along a way: `forward` follows its node order, `backward` goes against it. */
4
+ export type NeighborDirection = 'backward' | 'forward';
5
+ /** One OSM way as a chain member. */
3
6
  export type Segment = {
4
7
  id: number;
5
8
  version: number;
6
9
  nodeIds: number[];
7
10
  tags: OsmTags;
8
11
  geometry: LineString;
9
- /** True when geometry direction was flipped relative to chain traversal */
12
+ /** True when node order, geometry and directional tags were flipped to match the chain direction */
10
13
  reversed?: boolean;
11
14
  };
12
15
  export type SegmentChain = {
13
16
  /** Ordered segments: predecessors (left), center, successors (right) */
14
17
  segments: Segment[];
15
- /** Index of the reference/center segment */
18
+ /** Index of the center segment in `segments` */
16
19
  centerIndex: number;
17
20
  };
21
+ /** A junction where several neighbors are equally good; the user picks one of `candidates`. */
18
22
  export type JunctionChoice = {
19
23
  nodeId: number;
20
- direction: 'backward' | 'forward';
24
+ direction: NeighborDirection;
21
25
  candidates: Segment[];
22
26
  };
23
- export type NeighborDirection = 'backward' | 'forward';
package/dist/index.d.ts CHANGED
@@ -1,9 +1,9 @@
1
- export type { JunctionChoice, NeighborDirection, OsmTags, Segment, SegmentChain, } from './domain/types';
2
- export { createLegacyStreetRoadWayPolicy, 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 { buildWaysOverpassQuery, compileOverpassWaySelector, compileOverpassWaySelectors, matchesOsmWaySelection, matchesOsmWaySelectionSegment, tag, type OsmTagPredicate, type OsmWayFilterClause, type OsmWaySelectionPolicy, } from './way-selection-policy';
1
+ export type { JunctionChoice, NeighborDirection, Segment, SegmentChain } from './domain/types';
4
2
  export type { OsmDataAdapter } from './ports/OsmDataAdapter';
5
3
  export { createSessionGraphAdapter, osmWayToSegment } from './session-graph-adapter';
6
- export { buildChain, extendChainAtJunction, getOtherEndpointNodeId, getSharedNodeBetween, recenterChain, type BuildChainOptions, type BuildChainResult, } from './traversal/buildChain';
7
- export { isReversedAtNode, normalizeTagsForDirection, orientNeighbor, swapLeftRightKey, } from './traversal/direction';
4
+ export { buildChain, extendChainAtJunction, recenterChain, type BuildChainOptions, type BuildChainResult, type ExtendChainAtJunctionOptions, } from './traversal/buildChain';
5
+ export type { CandidateFilter } from './traversal/neighborMatch';
6
+ export { getEndpointNodeId, getOtherEndpointNodeId, getSharedNodeBetween, } from './traversal/endpoints';
7
+ export { normalizeTagsForDirection, orientNeighbor, swapLeftRightKey } from './traversal/direction';
8
8
  export { mirrorTags } from './traversal/mirror-tags';
9
- export { type CandidateFilter, filterNeighborCandidates, getEndpointNodeId, getKindKey, pickBestNeighbor, sameKind, scoreNeighborCandidate, } from './traversal/neighborMatch';
9
+ export { buildWaysOverpassQuery, compileOverpassWaySelector, compileOverpassWaySelectors, matchesOsmWaySelection, matchesOsmWaySelectionSegment, tag, type OsmTagPredicate, type OsmWayFilterClause, type OsmWaySelectionPolicy, } from './way-selection-policy';
package/dist/index.js CHANGED
@@ -1,155 +1,3 @@
1
- // src/way-selection-policy.ts
2
- var tag = {
3
- eq: (key, value) => ({ key, op: "eq", value }),
4
- neq: (key, value) => ({ key, op: "neq", value }),
5
- oneOf: (key, values) => ({
6
- key,
7
- op: "in",
8
- values
9
- }),
10
- noneOf: (key, values) => ({
11
- key,
12
- op: "nin",
13
- values
14
- }),
15
- regex: (key, pattern) => ({ key, op: "regex", pattern }),
16
- nregex: (key, pattern) => ({ key, op: "nregex", pattern }),
17
- present: (key) => ({ key, op: "present" }),
18
- absent: (key) => ({ key, op: "absent" })
19
- };
20
- function escapeRegexAlternation(values) {
21
- return values.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
22
- }
23
- function quoteOverpassValue(value) {
24
- if (/^[A-Za-z0-9_:-]+$/.test(value)) return value;
25
- return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
26
- }
27
- function escapeOverpassRegex(pattern) {
28
- return pattern.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
29
- }
30
- function compileTagPredicate(predicate) {
31
- switch (predicate.op) {
32
- case "eq":
33
- return `[${predicate.key}=${quoteOverpassValue(predicate.value)}]`;
34
- case "neq":
35
- return `[${predicate.key}!=${quoteOverpassValue(predicate.value)}]`;
36
- case "in":
37
- return `[${predicate.key}~"^(${escapeRegexAlternation(predicate.values)})$"]`;
38
- case "nin":
39
- return `[${predicate.key}!~"^(${escapeRegexAlternation(predicate.values)})$"]`;
40
- case "regex":
41
- return `[${predicate.key}~"${escapeOverpassRegex(predicate.pattern)}"]`;
42
- case "nregex":
43
- return `[${predicate.key}!~"${escapeOverpassRegex(predicate.pattern)}"]`;
44
- case "present":
45
- return `[${predicate.key}]`;
46
- case "absent":
47
- return `[!${predicate.key}]`;
48
- }
49
- }
50
- function clausePredicates(policy, clause) {
51
- return [...clause.all, ...policy.globalAll ?? []];
52
- }
53
- function compileOverpassWaySelectors(policy) {
54
- if (policy.include.length === 0) {
55
- throw new Error("OsmWaySelectionPolicy.include must contain at least one clause");
56
- }
57
- return policy.include.map(
58
- (clause) => clausePredicates(policy, clause).map(compileTagPredicate).join("")
59
- );
60
- }
61
- function compileOverpassWaySelector(policy) {
62
- const selectors = compileOverpassWaySelectors(policy);
63
- if (selectors.length !== 1) {
64
- throw new Error(
65
- `compileOverpassWaySelector expects exactly one include clause, got ${selectors.length}`
66
- );
67
- }
68
- return selectors[0];
69
- }
70
- function matchesTagPredicate(tags, predicate) {
71
- const raw = tags[predicate.key];
72
- switch (predicate.op) {
73
- case "eq":
74
- return raw === predicate.value;
75
- case "neq":
76
- return raw !== predicate.value;
77
- case "in":
78
- return raw != null && predicate.values.includes(raw);
79
- case "nin":
80
- return raw == null || !predicate.values.includes(raw);
81
- case "regex":
82
- return raw != null && new RegExp(predicate.pattern).test(raw);
83
- case "nregex":
84
- return raw == null || !new RegExp(predicate.pattern).test(raw);
85
- case "present":
86
- return raw != null && raw !== "";
87
- case "absent":
88
- return raw == null || raw === "";
89
- }
90
- }
91
- function matchesClause(tags, predicates) {
92
- return predicates.every((predicate) => matchesTagPredicate(tags, predicate));
93
- }
94
- function matchesOsmWaySelection(tags, policy) {
95
- return policy.include.some((clause) => matchesClause(tags, clausePredicates(policy, clause)));
96
- }
97
- function matchesOsmWaySelectionSegment(segment, policy) {
98
- return matchesOsmWaySelection(segment.tags, policy);
99
- }
100
- function buildWaysOverpassQuery(policy, bbox, options) {
101
- const selectors = compileOverpassWaySelectors(policy);
102
- const timeout = options?.timeoutSeconds ?? 60;
103
- const out = options?.out ?? "meta";
104
- const union = selectors.map((selector) => `way${selector}(${bbox});`).join("");
105
- return `[out:xml][timeout:${timeout}];(${union});(._;>;);out ${out};`;
106
- }
107
-
108
- // src/highway-filter.ts
109
- var DEFAULT_HIGHWAY_INCLUSION_STYLE = "public";
110
- function createLegacyStreetRoadWayPolicy(style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
111
- const highway = tag.regex(
112
- "highway",
113
- "^(motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service)(_link)?$|^busway$"
114
- );
115
- if (style === "inclusive") {
116
- return { include: [{ all: [highway] }] };
117
- }
118
- return {
119
- include: [{ all: [highway] }],
120
- globalAll: [
121
- tag.neq("access", "private"),
122
- tag.noneOf("service", ["emergency_access", "parking_aisle", "driveway"])
123
- ]
124
- };
125
- }
126
- var ROAD_LIKE_HIGHWAY_BASE_REGEX = /^motorway|trunk|primary|secondary|tertiary|unclassified|residential|living_street|service/;
127
- var OVERPASS_PUBLIC_ROAD_FILTERS = "[service!=emergency_access][service!=parking_aisle][service!=driveway][access!=private]";
128
- function overpassRoadLikeSelector(style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
129
- return compileOverpassWaySelector(createLegacyStreetRoadWayPolicy(style)).slice(1);
130
- }
131
- function isClutterAccessWay(tags) {
132
- if (tags.access === "private") return true;
133
- const service = tags.service;
134
- return service === "driveway" || service === "emergency_access" || service === "parking_aisle";
135
- }
136
- function matchesHighwayInclusionStyle(tags, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
137
- if (style === "inclusive") return true;
138
- return !isClutterAccessWay(tags);
139
- }
140
- function isRoadLikeHighway(tags) {
141
- return matchesOsmWaySelection(tags, createLegacyStreetRoadWayPolicy("inclusive"));
142
- }
143
- function isEditableRoadLikeHighway(tags, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
144
- return matchesOsmWaySelection(tags, createLegacyStreetRoadWayPolicy(style));
145
- }
146
- function isRoadLikeSegment(segment) {
147
- return isRoadLikeHighway(segment.tags);
148
- }
149
- function isEditableRoadLikeSegment(segment, style = DEFAULT_HIGHWAY_INCLUSION_STYLE) {
150
- return isEditableRoadLikeHighway(segment.tags, style);
151
- }
152
-
153
1
  // src/session-graph-adapter.ts
154
2
  function wayCoordinates(way, nodeCoords) {
155
3
  return way.nodes.map((nodeId) => {
@@ -315,7 +163,7 @@ function isBareSideKey(key) {
315
163
  const lower = key.toLowerCase();
316
164
  return lower === "sidewalk" || lower === "cycleway" || lower === "busway";
317
165
  }
318
- function mirrorTagValue(key, value, tags, laneCount) {
166
+ function mirrorTagValue(key, value, laneCount) {
319
167
  if (isOnewayKey(key)) return mirrorOnewayValue(value);
320
168
  if (isPlacementKey(key)) return mirrorPlacementValue(value, laneCount);
321
169
  if (isBareSideKey(key)) return mirrorBareSideValue(value);
@@ -327,7 +175,7 @@ function mirrorTags(tags) {
327
175
  const mirrored = {};
328
176
  for (const [key, value] of Object.entries(tags)) {
329
177
  const mirroredKey = mirrorDirectionalKey(key);
330
- mirrored[mirroredKey] = mirrorTagValue(key, value, tags, laneCount);
178
+ mirrored[mirroredKey] = mirrorTagValue(key, value, laneCount);
331
179
  }
332
180
  return mirrored;
333
181
  }
@@ -352,25 +200,44 @@ function normalizeTagsForDirection(tags, reversed) {
352
200
  function orientNeighbor(neighbor, sharedNodeId, fromSegment) {
353
201
  const reversed = isReversedAtNode(fromSegment, neighbor, sharedNodeId);
354
202
  if (!reversed) return { ...neighbor, reversed: false };
355
- const reversedNodeIds = [...neighbor.nodeIds].reverse();
356
- const reversedCoords = [...neighbor.geometry.coordinates].reverse();
357
203
  return {
358
204
  ...neighbor,
359
- nodeIds: reversedNodeIds,
205
+ nodeIds: [...neighbor.nodeIds].reverse(),
360
206
  geometry: {
361
207
  type: "LineString",
362
- coordinates: reversedCoords
208
+ coordinates: [...neighbor.geometry.coordinates].reverse()
363
209
  },
364
- tags: normalizeTagsForDirection(neighbor.tags, true),
210
+ tags: mirrorTags(neighbor.tags),
365
211
  reversed: true
366
212
  };
367
213
  }
368
214
 
215
+ // src/traversal/endpoints.ts
216
+ function getEndpointNodeId(segment, direction) {
217
+ const { nodeIds } = segment;
218
+ return direction === "forward" ? nodeIds[nodeIds.length - 1] : nodeIds[0];
219
+ }
220
+ function getOtherEndpointNodeId(segment, sharedNodeId) {
221
+ const first = segment.nodeIds[0];
222
+ const last = segment.nodeIds[segment.nodeIds.length - 1];
223
+ if (first === sharedNodeId) return last;
224
+ if (last === sharedNodeId) return first;
225
+ return void 0;
226
+ }
227
+ function getSharedNodeBetween(a, b) {
228
+ const aEnds = /* @__PURE__ */ new Set([a.nodeIds[0], a.nodeIds[a.nodeIds.length - 1]]);
229
+ for (const nodeId of [b.nodeIds[0], b.nodeIds[b.nodeIds.length - 1]]) {
230
+ if (nodeId != null && aEnds.has(nodeId)) return nodeId;
231
+ }
232
+ return void 0;
233
+ }
234
+
369
235
  // src/traversal/neighborMatch.ts
370
236
  var KIND_KEYS = ["highway", "cycleway", "footway", "path", "railway", "waterway"];
371
237
  function getKindKey(tags) {
372
238
  for (const key of KIND_KEYS) {
373
- if (tags[key]) return { key, value: tags[key] };
239
+ const value = tags[key];
240
+ if (value) return { key, value };
374
241
  }
375
242
  return void 0;
376
243
  }
@@ -380,17 +247,6 @@ function sameKind(a, b) {
380
247
  if (!kindA || !kindB) return false;
381
248
  return kindA.key === kindB.key && kindA.value === kindB.value;
382
249
  }
383
- function getEndpointNodeId(segment, direction) {
384
- const { nodeIds } = segment;
385
- return direction === "forward" ? nodeIds[nodeIds.length - 1] : nodeIds[0];
386
- }
387
- function getOtherEndpointNodeId(segment, sharedNodeId) {
388
- const first = segment.nodeIds[0];
389
- const last = segment.nodeIds[segment.nodeIds.length - 1];
390
- if (first === sharedNodeId) return last;
391
- if (last === sharedNodeId) return first;
392
- return void 0;
393
- }
394
250
  function filterNeighborCandidates(from, candidates, direction, candidateFilter) {
395
251
  const endpoint = getEndpointNodeId(from, direction);
396
252
  const touching = candidates.filter((c) => {
@@ -470,10 +326,10 @@ async function buildChain(adapter, options) {
470
326
  pendingJunctions: [...backwardResult.pendingJunctions, ...forwardResult.pendingJunctions]
471
327
  };
472
328
  }
473
- async function extendChainAtJunction(adapter, chain, choice, selectedWayId, maxCount = DEFAULT_MAX_PER_SIDE, candidateFilter) {
329
+ async function extendChainAtJunction(adapter, chain, choice, selectedWayId, options = {}) {
330
+ const maxPerSide = options.maxPerSide ?? DEFAULT_MAX_PER_SIDE;
474
331
  const cache = new Map(chain.segments.map((s) => [s.id, s]));
475
- const selected = cache.get(selectedWayId) ?? await adapter.getWay(selectedWayId);
476
- cache.set(selected.id, selected);
332
+ const selected = await loadSegment(adapter, selectedWayId, cache);
477
333
  const anchorIndex = choice.direction === "forward" ? chain.segments.length - 1 : 0;
478
334
  const anchor = chain.segments[anchorIndex];
479
335
  const oriented = orientNeighbor(selected, choice.nodeId, anchor);
@@ -482,15 +338,10 @@ async function extendChainAtJunction(adapter, chain, choice, selectedWayId, maxC
482
338
  cache,
483
339
  oriented,
484
340
  choice.direction,
485
- maxCount,
486
- candidateFilter
341
+ maxPerSide,
342
+ options.candidateFilter
487
343
  );
488
- let segments;
489
- if (choice.direction === "forward") {
490
- segments = [...chain.segments, oriented, ...extension];
491
- } else {
492
- segments = [...extension.reverse(), oriented, ...chain.segments];
493
- }
344
+ const segments = choice.direction === "forward" ? [...chain.segments, oriented, ...extension] : [...extension.reverse(), oriented, ...chain.segments];
494
345
  return {
495
346
  chain: {
496
347
  segments,
@@ -504,12 +355,121 @@ function recenterChain(chain, newCenterWayId) {
504
355
  if (index === -1) return chain;
505
356
  return { segments: chain.segments, centerIndex: index };
506
357
  }
507
- function getSharedNodeBetween(a, b) {
508
- const aEnds = /* @__PURE__ */ new Set([a.nodeIds[0], a.nodeIds[a.nodeIds.length - 1]]);
509
- for (const nodeId of [b.nodeIds[0], b.nodeIds[b.nodeIds.length - 1]]) {
510
- if (aEnds.has(nodeId)) return nodeId;
358
+
359
+ // src/way-selection-policy.ts
360
+ var tag = {
361
+ eq: (key, value) => ({ key, op: "eq", value }),
362
+ neq: (key, value) => ({ key, op: "neq", value }),
363
+ oneOf: (key, values) => ({
364
+ key,
365
+ op: "in",
366
+ values
367
+ }),
368
+ noneOf: (key, values) => ({
369
+ key,
370
+ op: "nin",
371
+ values
372
+ }),
373
+ regex: (key, pattern) => ({ key, op: "regex", pattern }),
374
+ nregex: (key, pattern) => ({ key, op: "nregex", pattern }),
375
+ present: (key) => ({ key, op: "present" }),
376
+ absent: (key) => ({ key, op: "absent" })
377
+ };
378
+ function escapeRegexAlternation(values) {
379
+ return values.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
380
+ }
381
+ function quoteOverpassValue(value) {
382
+ if (/^[A-Za-z0-9_:-]+$/.test(value)) return value;
383
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
384
+ }
385
+ function escapeOverpassRegex(pattern) {
386
+ return pattern.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
387
+ }
388
+ function compileTagPredicate(predicate) {
389
+ switch (predicate.op) {
390
+ case "eq":
391
+ return `[${predicate.key}=${quoteOverpassValue(predicate.value)}]`;
392
+ case "neq":
393
+ return `[${predicate.key}!=${quoteOverpassValue(predicate.value)}]`;
394
+ case "in":
395
+ return `[${predicate.key}~"^(${escapeRegexAlternation(predicate.values)})$"]`;
396
+ case "nin":
397
+ return `[${predicate.key}!~"^(${escapeRegexAlternation(predicate.values)})$"]`;
398
+ case "regex":
399
+ return `[${predicate.key}~"${escapeOverpassRegex(predicate.pattern)}"]`;
400
+ case "nregex":
401
+ return `[${predicate.key}!~"${escapeOverpassRegex(predicate.pattern)}"]`;
402
+ case "present":
403
+ return `[${predicate.key}]`;
404
+ case "absent":
405
+ return `[!${predicate.key}]`;
511
406
  }
512
- return void 0;
407
+ }
408
+ function clausePredicates(policy, clause) {
409
+ return [...clause.all, ...policy.globalAll ?? []];
410
+ }
411
+ function compileOverpassWaySelectors(policy) {
412
+ if (policy.include.length === 0) {
413
+ throw new Error("OsmWaySelectionPolicy.include must contain at least one clause");
414
+ }
415
+ return policy.include.map(
416
+ (clause) => clausePredicates(policy, clause).map(compileTagPredicate).join("")
417
+ );
418
+ }
419
+ function compileOverpassWaySelector(policy) {
420
+ const selectors = compileOverpassWaySelectors(policy);
421
+ if (selectors.length !== 1) {
422
+ throw new Error(
423
+ `compileOverpassWaySelector expects exactly one include clause, got ${selectors.length}`
424
+ );
425
+ }
426
+ return selectors[0];
427
+ }
428
+ var regexCache = /* @__PURE__ */ new Map();
429
+ function cachedRegex(pattern) {
430
+ let regex = regexCache.get(pattern);
431
+ if (!regex) {
432
+ regex = new RegExp(pattern);
433
+ regexCache.set(pattern, regex);
434
+ }
435
+ return regex;
436
+ }
437
+ function matchesTagPredicate(tags, predicate) {
438
+ const raw = tags[predicate.key];
439
+ switch (predicate.op) {
440
+ case "eq":
441
+ return raw === predicate.value;
442
+ case "neq":
443
+ return raw !== predicate.value;
444
+ case "in":
445
+ return raw != null && predicate.values.includes(raw);
446
+ case "nin":
447
+ return raw == null || !predicate.values.includes(raw);
448
+ case "regex":
449
+ return raw != null && cachedRegex(predicate.pattern).test(raw);
450
+ case "nregex":
451
+ return raw == null || !cachedRegex(predicate.pattern).test(raw);
452
+ case "present":
453
+ return raw != null && raw !== "";
454
+ case "absent":
455
+ return raw == null || raw === "";
456
+ }
457
+ }
458
+ function matchesClause(tags, predicates) {
459
+ return predicates.every((predicate) => matchesTagPredicate(tags, predicate));
460
+ }
461
+ function matchesOsmWaySelection(tags, policy) {
462
+ return policy.include.some((clause) => matchesClause(tags, clausePredicates(policy, clause)));
463
+ }
464
+ function matchesOsmWaySelectionSegment(segment, policy) {
465
+ return matchesOsmWaySelection(segment.tags, policy);
466
+ }
467
+ function buildWaysOverpassQuery(policy, bbox, options) {
468
+ const selectors = compileOverpassWaySelectors(policy);
469
+ const timeout = options?.timeoutSeconds ?? 60;
470
+ const out = options?.out ?? "meta";
471
+ const union = selectors.map((selector) => `way${selector}(${bbox});`).join("");
472
+ return `[out:xml][timeout:${timeout}];(${union});(._;>;);out ${out};`;
513
473
  }
514
474
 
515
- export { DEFAULT_HIGHWAY_INCLUSION_STYLE, OVERPASS_PUBLIC_ROAD_FILTERS, ROAD_LIKE_HIGHWAY_BASE_REGEX, buildChain, buildWaysOverpassQuery, compileOverpassWaySelector, compileOverpassWaySelectors, createLegacyStreetRoadWayPolicy, createSessionGraphAdapter, extendChainAtJunction, filterNeighborCandidates, getEndpointNodeId, getKindKey, getOtherEndpointNodeId, getSharedNodeBetween, isClutterAccessWay, isEditableRoadLikeHighway, isEditableRoadLikeSegment, isReversedAtNode, isRoadLikeHighway, isRoadLikeSegment, matchesHighwayInclusionStyle, matchesOsmWaySelection, matchesOsmWaySelectionSegment, mirrorTags, normalizeTagsForDirection, orientNeighbor, osmWayToSegment, overpassRoadLikeSelector, pickBestNeighbor, recenterChain, sameKind, scoreNeighborCandidate, swapLeftRightKey, tag };
475
+ export { buildChain, buildWaysOverpassQuery, compileOverpassWaySelector, compileOverpassWaySelectors, createSessionGraphAdapter, extendChainAtJunction, getEndpointNodeId, getOtherEndpointNodeId, getSharedNodeBetween, matchesOsmWaySelection, matchesOsmWaySelectionSegment, mirrorTags, normalizeTagsForDirection, orientNeighbor, osmWayToSegment, recenterChain, swapLeftRightKey, tag };
@@ -1,5 +1,8 @@
1
1
  import type { Segment } from '../domain/types';
2
+ /** Data source for chain building. */
2
3
  export type OsmDataAdapter = {
4
+ /** Rejects when the way is unknown. */
3
5
  getWay: (wayId: number) => Promise<Segment>;
6
+ /** All ways that use this node, at any position. */
4
7
  getWaysForNode: (nodeId: number) => Promise<Segment[]>;
5
8
  };
@@ -1,9 +1,10 @@
1
1
  import type { OsmWay, ParsedOsmData } from '@osm-editor-kit/osm-data';
2
2
  import type { Segment } from './domain/types';
3
3
  import type { OsmDataAdapter } from './ports/OsmDataAdapter';
4
+ /** Nodes without coordinates in `nodeCoords` are left out of the geometry. */
4
5
  export declare function osmWayToSegment(way: OsmWay, nodeCoords: ParsedOsmData['nodeCoords']): Segment;
5
6
  /**
6
- * Adapter backed by an already-loaded session graph (bbox coverage data).
7
+ * Adapter backed by already loaded `ParsedOsmData`.
7
8
  * Lookups are synchronous but wrapped in Promises to satisfy `OsmDataAdapter`.
8
9
  */
9
10
  export declare function createSessionGraphAdapter(graph: ParsedOsmData): OsmDataAdapter;
@@ -1,20 +1,38 @@
1
1
  import type { JunctionChoice, Segment, SegmentChain } from '../domain/types';
2
2
  import type { OsmDataAdapter } from '../ports/OsmDataAdapter';
3
- import { type CandidateFilter, getOtherEndpointNodeId } from './neighborMatch';
3
+ import { type CandidateFilter } from './neighborMatch';
4
4
  export type BuildChainOptions = {
5
5
  centerWayId: number;
6
+ /** Maximum number of ways to add on each side of the center. Default 5. */
6
7
  maxPerSide?: number;
7
- /** Already loaded segments keyed by id */
8
+ /** Already loaded segments keyed by id; skips `adapter.getWay` for these. */
8
9
  knownSegments?: Map<number, Segment>;
9
- /** Optional predicate to restrict neighbor candidates (e.g. road-like highways) */
10
+ /** Restricts which ways may join the chain. */
11
+ candidateFilter?: CandidateFilter;
12
+ };
13
+ export type ExtendChainAtJunctionOptions = {
14
+ /** Maximum number of ways to add after the selected way. Default 5. */
15
+ maxPerSide?: number;
16
+ /** Restricts which ways may join the chain. */
10
17
  candidateFilter?: CandidateFilter;
11
18
  };
12
19
  export type BuildChainResult = {
13
20
  chain: SegmentChain;
14
21
  pendingJunctions: JunctionChoice[];
15
22
  };
23
+ /**
24
+ * Walks connected ways backward and forward from `centerWayId` and orients them into one chain.
25
+ * Stops on each side at `maxPerSide`, at a dead end, or at a junction it cannot decide
26
+ * (reported in `pendingJunctions`).
27
+ */
16
28
  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>;
29
+ /**
30
+ * Continues `chain` at a pending junction with the way the user picked, then keeps walking
31
+ * in the junction's direction. The center way stays the same.
32
+ */
33
+ export declare function extendChainAtJunction(adapter: OsmDataAdapter, chain: SegmentChain, choice: JunctionChoice, selectedWayId: number, options?: ExtendChainAtJunctionOptions): Promise<BuildChainResult>;
34
+ /**
35
+ * Moves the center to `newCenterWayId` without reloading.
36
+ * Returns `chain` itself, unchanged, when the way is not part of the chain.
37
+ */
18
38
  export declare function recenterChain(chain: SegmentChain, newCenterWayId: number): SegmentChain;
19
- export declare function getSharedNodeBetween(a: Segment, b: Segment): number | undefined;
20
- export { getOtherEndpointNodeId };
@@ -1,5 +1,13 @@
1
+ import type { OsmTags } from '@osm-editor-kit/osm-data';
1
2
  import type { Segment } from '../domain/types';
3
+ /** True when both segments start (or both end) at `sharedNodeId`, so one runs against the other. */
2
4
  export declare function isReversedAtNode(current: Segment, neighbor: Segment, sharedNodeId: number): boolean;
5
+ /** Swaps the first `:left` / `:right` part of a tag key, e.g. `cycleway:left:surface`. */
3
6
  export declare function swapLeftRightKey(key: string): string;
4
- export declare function normalizeTagsForDirection(tags: Segment['tags'], reversed: boolean): import("..").OsmTags;
7
+ /** `mirrorTags(tags)` when `reversed`, otherwise `tags` itself. */
8
+ export declare function normalizeTagsForDirection(tags: OsmTags, reversed: boolean): OsmTags;
9
+ /**
10
+ * Re-expresses `neighbor` in the direction of `fromSegment`: when they run against each other
11
+ * at `sharedNodeId`, node order, geometry and directional tags are flipped and `reversed` is set.
12
+ */
5
13
  export declare function orientNeighbor(neighbor: Segment, sharedNodeId: number, fromSegment: Segment): Segment;
@@ -0,0 +1,7 @@
1
+ import type { NeighborDirection, Segment } from '../domain/types';
2
+ /** Last node for `forward`, first node for `backward`. */
3
+ export declare function getEndpointNodeId(segment: Segment, direction: NeighborDirection): number;
4
+ /** The endpoint opposite `sharedNodeId`, or `undefined` when `sharedNodeId` is not an endpoint. */
5
+ export declare function getOtherEndpointNodeId(segment: Segment, sharedNodeId: number): number | undefined;
6
+ /** The node where the endpoints of `a` and `b` meet, or `undefined` when they do not touch. */
7
+ export declare function getSharedNodeBetween(a: Segment, b: Segment): number | undefined;
@@ -1,5 +1,6 @@
1
+ import type { OsmTags } from '@osm-editor-kit/osm-data';
1
2
  /**
2
3
  * Mirror OSM tags as if the way were digitised in the opposite direction.
3
4
  * Applying twice returns the original tags (deep equality).
4
5
  */
5
- export declare function mirrorTags(tags: Record<string, string>): Record<string, string>;
6
+ export declare function mirrorTags(tags: OsmTags): OsmTags;
@@ -1,14 +1,14 @@
1
1
  import type { NeighborDirection, Segment } from '../domain/types';
2
+ /** Restricts which ways may join a chain. */
2
3
  export type CandidateFilter = (segment: Segment) => boolean;
3
4
  export declare function getKindKey(tags: Segment['tags']): {
4
- key: "cycleway" | "footway" | "highway" | "path" | "railway" | "waterway";
5
+ key: string;
5
6
  value: string;
6
7
  } | undefined;
7
8
  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 */
9
+ /** Ways that start or end at `from`'s endpoint in `direction`; same-kind ways win if there are any. */
11
10
  export declare function filterNeighborCandidates(from: Segment, candidates: Segment[], direction: NeighborDirection, candidateFilter?: CandidateFilter): Segment[];
12
- /** Score candidate for auto-follow (higher = better) */
11
+ /** Score for auto-follow (higher = better) */
13
12
  export declare function scoreNeighborCandidate(from: Segment, candidate: Segment): number;
13
+ /** The neighbor to follow, or `undefined` when there is none or the top two tie below same kind. */
14
14
  export declare function pickBestNeighbor(from: Segment, candidates: Segment[]): Segment | undefined;
@@ -1,9 +1,13 @@
1
1
  import type { OsmTags } from '@osm-editor-kit/osm-data';
2
2
  import type { Segment } from './domain/types';
3
3
  /**
4
- * Atomic tag predicate. Compiles 1:1 to Overpass filter syntax and the same
5
- * semantics when matching in-memory `OsmTags` (missing tag matches `neq` / `nin`
6
- * / `absent`, same as Overpass).
4
+ * Atomic tag predicate. Compiles 1:1 to an Overpass tag filter and matches in-memory `OsmTags`
5
+ * the same way: a missing tag matches `neq`, `nin`, `nregex` and `absent`.
6
+ *
7
+ * `regex` / `nregex` patterns run as JavaScript `RegExp` in memory but go to Overpass unchanged,
8
+ * where they are POSIX extended regular expressions. Use only syntax both understand (anchors,
9
+ * groups, `|`, `[...]`, `*`, `+`, `?`, `{n,m}`); `\d`, `(?:...)`, lookarounds or lazy quantifiers
10
+ * will differ or fail in Overpass.
7
11
  */
8
12
  export type OsmTagPredicate = {
9
13
  readonly key: string;
@@ -56,6 +60,7 @@ export declare function compileOverpassWaySelectors(policy: OsmWaySelectionPolic
56
60
  export declare function compileOverpassWaySelector(policy: OsmWaySelectionPolicy): string;
57
61
  /** Whether OSM tags match the policy (OR of include clauses, each with globalAll). */
58
62
  export declare function matchesOsmWaySelection(tags: OsmTags, policy: OsmWaySelectionPolicy): boolean;
63
+ /** {@link matchesOsmWaySelection} for a segment's tags, e.g. as a `candidateFilter`. */
59
64
  export declare function matchesOsmWaySelectionSegment(segment: Segment, policy: OsmWaySelectionPolicy): boolean;
60
65
  /** Builds Overpass QL that downloads matching ways (+ their nodes) for a bbox string. */
61
66
  export declare function buildWaysOverpassQuery(policy: OsmWaySelectionPolicy, bbox: string, options?: {
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@osm-editor-kit/osm-way-chain",
3
- "version": "0.1.0-alpha.4",
3
+ "version": "1.0.1",
4
4
  "description": "Traverse and orient connected OSM highway ways for multi-segment editing sessions.",
5
- "homepage": "https://github.com/osmberlin/street-space-editor/tree/main/packages/osm-way-chain",
5
+ "homepage": "https://github.com/osm-editor-kit/street-space-editor/tree/main/packages/osm-way-chain",
6
6
  "license": "MIT",
7
7
  "author": "Tobias Jordans",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/osmberlin/street-space-editor.git",
10
+ "url": "git+https://github.com/osm-editor-kit/street-space-editor.git",
11
11
  "directory": "packages/osm-way-chain"
12
12
  },
13
13
  "files": [
@@ -23,8 +23,7 @@
23
23
  "./package.json": "./package.json"
24
24
  },
25
25
  "publishConfig": {
26
- "access": "public",
27
- "tag": "alpha"
26
+ "access": "public"
28
27
  },
29
28
  "scripts": {
30
29
  "build": "tsup && rm -f .tsbuildinfo && tsc -p tsconfig.build.json",
@@ -36,10 +35,10 @@
36
35
  "postpack": "bun ../../scripts/package-exports-restore.ts"
37
36
  },
38
37
  "dependencies": {
39
- "@osm-editor-kit/osm-data": "^0.1.0-alpha.1"
38
+ "@osm-editor-kit/osm-data": "^1.0.1",
39
+ "@types/geojson": "^7946.0.16"
40
40
  },
41
41
  "devDependencies": {
42
- "@types/geojson": "^7946.0.16",
43
42
  "bun-types": "^1.2.20",
44
43
  "tsup": "^8.5.1",
45
44
  "typescript": "^7.0.0"
@@ -1,36 +0,0 @@
1
- import type { OsmTags } from '@osm-editor-kit/osm-data';
2
- import type { Segment } from './domain/types';
3
- import { type OsmWaySelectionPolicy } from './way-selection-policy';
4
- /**
5
- * @deprecated App-owned concept. Prefer {@link OsmWaySelectionPolicy} defined in the app.
6
- * Kept temporarily for street-space-editor call sites.
7
- */
8
- export type HighwayInclusionStyle = 'public' | 'inclusive';
9
- /** @deprecated Prefer app-owned policies. */
10
- export declare const DEFAULT_HIGHWAY_INCLUSION_STYLE: HighwayInclusionStyle;
11
- /**
12
- * @deprecated Prefer app-owned {@link OsmWaySelectionPolicy}.
13
- * Legacy lane-mode car/road selection (incl. `*_link` and `busway`).
14
- */
15
- export declare function createLegacyStreetRoadWayPolicy(style?: HighwayInclusionStyle): OsmWaySelectionPolicy;
16
- /** @deprecated Prefer matching against an app policy. */
17
- export declare const ROAD_LIKE_HIGHWAY_BASE_REGEX: RegExp;
18
- /** @deprecated Prefer policy `globalAll`. */
19
- export declare const OVERPASS_PUBLIC_ROAD_FILTERS = "[service!=emergency_access][service!=parking_aisle][service!=driveway][access!=private]";
20
- /**
21
- * @deprecated Prefer {@link compileOverpassWaySelector} with an app policy.
22
- * Returns the legacy fragment expected by `way[${tag}]` callers (no leading `[`).
23
- */
24
- export declare function overpassRoadLikeSelector(style?: HighwayInclusionStyle): string;
25
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelection}. */
26
- export declare function isClutterAccessWay(tags: OsmTags): boolean;
27
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelection}. */
28
- export declare function matchesHighwayInclusionStyle(tags: OsmTags, style?: HighwayInclusionStyle): boolean;
29
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelection}. */
30
- export declare function isRoadLikeHighway(tags: OsmTags): boolean;
31
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelection}. */
32
- export declare function isEditableRoadLikeHighway(tags: OsmTags, style?: HighwayInclusionStyle): boolean;
33
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelectionSegment}. */
34
- export declare function isRoadLikeSegment(segment: Segment): boolean;
35
- /** @deprecated Prefer app policy + {@link matchesOsmWaySelectionSegment}. */
36
- export declare function isEditableRoadLikeSegment(segment: Segment, style?: HighwayInclusionStyle): boolean;