Skip to content

Commit f790d5a

Browse files
committed
define a schema for relation members
1 parent 62b6950 commit f790d5a

File tree

4 files changed

+205
-6
lines changed

4 files changed

+205
-6
lines changed

README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,97 @@ For example,
386386
}
387387
```
388388

389+
##### `relation`
390+
391+
For relations, this object describes which roles are allowed, which tags are required for each role, and other constraints related to relation roles.
392+
393+
* `relation.id` – a string. This is the “permanent relation type ID”, it should match the value of [permanent relation type ID<sup><code>P41</code></sup>](https://osm.wiki/Property:P41) in the OSM wiki’s wikibase system.
394+
* `relation.allowDuplicateMembers` – a boolean. Set to `true` if the same OSM feature is allowed to appear multiple times in the relation's members.
395+
* `relation.optionalTags` – an object, only useful for specifying placeholders which are referenced in `members.*.matchTags`. See example below.
396+
* `relation.members` – an array of objects, which lists every member that is permitted (see below).
397+
398+
A full example looks like this:
399+
400+
```jsonc
401+
// type/restriction/no_left_turn.json
402+
{
403+
"relation": {
404+
"id": "restriction", // the value of https://osm.wiki/Property:P41 from the matching
405+
// item, in this case https://wiki.openstreetmap.org/wiki/Item:Q16054. Note that multiple
406+
// relation entries may have the same `id` value.
407+
"allowDuplicateMembers": true,
408+
"members": [
409+
{
410+
"role": "from", // The relation role. An empty string is allowed
411+
"roleLabel": "From", // The label for the role, in the default language. An empty string is allowed
412+
"geometry": ["line"], // If not specified, any geometry is allowed
413+
"matchTags": [
414+
// Describes which tags the member must have, if it has this role.
415+
// `*` can be used as a tag value.
416+
// If multiple array items are specified, only 1 needs to match.
417+
// If this property is not specified, then any tags are allowed
418+
{ "highway": "*" },
419+
],
420+
"min": 1, // minimum number of times that this role must appear in the relation
421+
"max": 1, // maximum number of times that this role must appear in the relation
422+
},
423+
{
424+
"role": "via",
425+
"roleLabel": "Via",
426+
"geometry": ["vertex", "line"],
427+
"min": 1,
428+
},
429+
{
430+
"role": "to",
431+
"roleLabel": "To",
432+
"geometry": ["line"],
433+
"matchTags": [{ "highway": "*" }],
434+
"min": 1,
435+
"max": 1,
436+
},
437+
],
438+
},
439+
}
440+
```
441+
442+
It is possible for the `matchTags` constraints to reference tags from the relation.
443+
444+
For example, if a relation with [`type=route`](https://osm.wiki/Tag:type=route) + [`route=XXXX`](https://osm.wiki/Key:route) has a member with a role of `stop`, then that member must have a tag of `XXXX=yes`<sup>[[1]](https://osm.wiki/Relation:route#Members)</sup>. This can be expressed using the following syntax:
445+
446+
```jsonc
447+
// type/route.json
448+
{
449+
"relation": {
450+
"optionalTags": {
451+
"route": "$1", // 👈 the tag value can be referenced below using $1
452+
},
453+
"members": [
454+
{
455+
"role": "stop",
456+
"roleLabel": "Stop Position",
457+
"geometry": ["point", "vertex"],
458+
"matchTags": [
459+
{
460+
"public_transport": "stop_position",
461+
"$1": "yes", // 👈 the member must have $1=yes. $1 is determined
462+
// by the value of route=* on the relation.
463+
},
464+
],
465+
},
466+
],
467+
},
468+
}
469+
```
470+
471+
##### `relationCrossReference`
472+
473+
To avoid repeating the [`relation` object](#relation) in several presets, you can use `relationCrossReference` to reference another preset.
474+
475+
For example:
476+
```js
477+
"relationCrossReference": "{type/route}"
478+
```
479+
389480
### Fields
390481

391482
Fields are reusable form elements that can be associated with presets.

lib/build.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { fileURLToPath } from 'node:url';
22
import { styleText } from 'node:util';
33
import fs from 'fs';
4-
import jsonschema from 'jsonschema';
4+
import { Validator } from 'jsonschema';
55
import path from 'path';
66
import shell from 'shelljs';
77
import YAML from 'js-yaml';
@@ -22,6 +22,8 @@ const discardedSchema = require('../schemas/discarded.json');
2222

2323
let _currBuild = null;
2424

25+
const jsonschema = new Validator();
26+
2527
function validateData(options) {
2628
const START = '🔬 ' + styleText('yellow', 'Validating schema...');
2729
const END = '👍 ' + styleText('green', 'schema okay');
@@ -205,6 +207,9 @@ function read(f) {
205207

206208

207209
function validateSchema(file, instance, schema) {
210+
// add this schema to the cache, so $ref can be resolved faster
211+
jsonschema.addSchema(schema);
212+
208213
let validationErrors = jsonschema.validate(instance, schema).errors;
209214

210215
if (validationErrors.length) {
@@ -366,6 +371,13 @@ function generatePresets(dataDir, tstrings, searchableFieldIDs, listReusedIcons)
366371
if (!icons[icon]) icons[icon] = [];
367372
icons[icon].push(id);
368373
}
374+
375+
if (preset.relation) {
376+
tstrings.presets[id].relation_roles = {};
377+
for (const member of preset.relation.members) {
378+
tstrings.presets[id].relation_roles[member.role] = member.roleLabel;
379+
}
380+
}
369381
});
370382

371383
if (listReusedIcons) {
@@ -465,8 +477,10 @@ function generateTranslations(fields, presets, tstrings, searchableFieldIDs) {
465477
let tags = preset.tags || {};
466478
let keys = Object.keys(tags);
467479

480+
const tagsString = keys.map(k => `${k}=${tags[k]}`).join(' + ');
481+
468482
if (keys.length) {
469-
yamlPreset['#name'] = keys.map(k => `${k}=${tags[k]}`).join(' + ');
483+
yamlPreset['#name'] = tagsString;
470484
if (yamlPreset.aliases) {
471485
yamlPreset['#name'] += ' | ' + yamlPreset.aliases.split('\n').join(', ');
472486
}
@@ -477,6 +491,12 @@ function generateTranslations(fields, presets, tstrings, searchableFieldIDs) {
477491
yamlPreset['#name'] += ` | Local preset for countries ${preset.locationSet.include.map(country => `"${country.toUpperCase()}"`).join(', ')}`;
478492
}
479493

494+
if (yamlPreset.relation_roles) {
495+
for (const role in yamlPreset.relation_roles) {
496+
yamlPreset.relation_roles[`#${role}`] = `Relation role “${role}” when used with ${tagsString}`;
497+
}
498+
}
499+
480500
if (preset.searchable !== false) {
481501
if (yamlPreset.terms) {
482502
yamlPreset['#terms'] = 'terms: ' + yamlPreset.terms;

schemas/field.json

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,7 @@
9797
"minItems": 1,
9898
"uniqueItems": true,
9999
"items": {
100-
"type": "string",
101-
"enum": ["point", "vertex", "line", "area", "relation"]
100+
"$ref": "#/$defs/Geometry"
102101
}
103102
},
104103
"default": {
@@ -382,5 +381,11 @@
382381
{ "required": ["keys"] }
383382
]}
384383
]}
385-
]
384+
],
385+
"$defs": {
386+
"Geometry": {
387+
"type": "string",
388+
"enum": ["point", "vertex", "line", "area", "relation"]
389+
}
390+
}
386391
}

schemas/preset.json

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,8 +135,91 @@
135135
},
136136
"minProperties": 1,
137137
"additionalProperties": false
138+
},
139+
"relation": {
140+
"$ref": "#/$defs/RelationSchema"
141+
},
142+
"relationCrossReference": {
143+
"description": "A preset can reference the relation schema from another preset",
144+
"type": "string",
145+
"pattern": "^\\{.+\\}$"
138146
}
139147
},
140148
"additionalProperties": false,
141-
"required": ["name", "geometry", "tags"]
149+
"required": ["name", "geometry", "tags"],
150+
"$defs": {
151+
"RelationSchema": {
152+
"type": "object",
153+
"properties": {
154+
"optionalTags": {
155+
"type": "object",
156+
"description": "Only useful for specifying placeholders which are referenced in members.*.matchTags",
157+
"examples": [{ "route": "$1" }],
158+
"additionalProperties": {
159+
"type": "string"
160+
},
161+
"minProperties": 1
162+
},
163+
"id": {
164+
"type": "string",
165+
"description": "The “permanent relation type ID”, this should match the value of https://osm.wiki/Property:P41 in the OSM wiki’s wikibase system."
166+
},
167+
"allowDuplicateMembers": {
168+
"type": "boolean",
169+
"description": "Set to `true` if the same OSM feature is allowed to appear multiple times in the relation's members."
170+
},
171+
"members": {
172+
"type": "array",
173+
"items": {
174+
"type": "object",
175+
"properties": {
176+
"role": {
177+
"type": "string",
178+
"description": "The relation role. An empty string is allowed."
179+
},
180+
"roleLabel": {
181+
"type": "string",
182+
"description": "The label for the role, in the default language. An empty string is allowed."
183+
},
184+
"geometry": {
185+
"type": "array",
186+
"uniqueItems": true,
187+
"items": {
188+
"$ref": "field.json#/$defs/Geometry"
189+
},
190+
"description": "If not specified, any geometry is allowed"
191+
},
192+
"matchTags": {
193+
"type": "array",
194+
"items": {
195+
"type": "object",
196+
"additionalProperties": {
197+
"type": "string"
198+
},
199+
"minProperties": 1
200+
},
201+
"examples": [
202+
[{ "a": 1, "b": 2 }],
203+
[{ "a": 1 }, { "b": 2 }]
204+
],
205+
"description": "`*` can be used as a tag value. If multiple array items are specified, only 1 needs to match. If not specified, then any tags are allowed"
206+
},
207+
"min": {
208+
"type": "integer",
209+
"description": "If unspecified, there is no minimum"
210+
},
211+
"max": {
212+
"type": "integer",
213+
"description": "If unspecified, there is no maximum"
214+
}
215+
},
216+
"required": ["role", "roleLabel"],
217+
"additionalProperties": false
218+
}
219+
}
220+
},
221+
"required": ["id", "allowDuplicateMembers", "members"],
222+
"additionalProperties": false
223+
}
224+
}
142225
}

0 commit comments

Comments
 (0)