Repository: graphhopper/graphhopper
Pull request #2245 (2021-03-04 14:27:46)

Incorrectly classified oneways
A [Kurviger](https://kurviger.de/) user just reported an interesting bug. A [road](https://www.openstreetmap.org/way/115560610) was classified as oneway from the routing engine even though it is not marked as a oneway.

The issue is created in the isOneway method, where we only check if a `*:forward ` or `*:backward` tag exists but not if this tag actually marks it as a oneway.

Therefore I propose to check if these tags are actually indicating that a restrictive value is set.

This situation doesn't seem to happen frequently, but there are a few ways like this.

boldtrn: I just saw that there is a similar check for bicycles as well, if you agree with these changes I could add the same for bicycle as well?

Patch:
diff --git a/core/src/main/java/com/graphhopper/routing/util/CarFlagEncoder.java b/core/src/main/java/com/graphhopper/routing/util/CarFlagEncoder.java
index bd03885a7..b7b28b83b 100644
--- a/core/src/main/java/com/graphhopper/routing/util/CarFlagEncoder.java
+++ b/core/src/main/java/com/graphhopper/routing/util/CarFlagEncoder.java
@@ -277,8 +277,8 @@ public class CarFlagEncoder extends AbstractFlagEncoder {
      */
     protected boolean isBackwardOneway(ReaderWay way) {
         return way.hasTag("oneway", "-1")
-                || way.hasTag("vehicle:forward", "no")
-                || way.hasTag("motor_vehicle:forward", "no");
+                || way.hasTag("vehicle:forward", restrictedValues)
+                || way.hasTag("motor_vehicle:forward", restrictedValues);
     }
 
     /**
@@ -286,16 +286,16 @@ public class CarFlagEncoder extends AbstractFlagEncoder {
      */
     protected boolean isForwardOneway(ReaderWay way) {
         return !way.hasTag("oneway", "-1")
-                && !way.hasTag("vehicle:forward", "no")
-                && !way.hasTag("motor_vehicle:forward", "no");
+                && !way.hasTag("vehicle:forward", restrictedValues)
+                && !way.hasTag("motor_vehicle:forward", restrictedValues);
     }
 
     protected boolean isOneway(ReaderWay way) {
         return way.hasTag("oneway", oneways)
-                || way.hasTag("vehicle:backward")
-                || way.hasTag("vehicle:forward")
-                || way.hasTag("motor_vehicle:backward")
-                || way.hasTag("motor_vehicle:forward");
+                || way.hasTag("vehicle:backward", restrictedValues)
+                || way.hasTag("vehicle:forward", restrictedValues)
+                || way.hasTag("motor_vehicle:backward", restrictedValues)
+                || way.hasTag("motor_vehicle:forward", restrictedValues);
     }
 
     /**
diff --git a/core/src/main/java/com/graphhopper/routing/weighting/custom/CustomModelParser.java b/core/src/main/java/com/graphhopper/routing/weighting/custom/CustomModelParser.java
index 0a59f591e..edbd21ac1 100644
--- a/core/src/main/java/com/graphhopper/routing/weighting/custom/CustomModelParser.java
+++ b/core/src/main/java/com/graphhopper/routing/weighting/custom/CustomModelParser.java
@@ -44,7 +44,7 @@ import java.util.concurrent.atomic.AtomicLong;
 
 public class CustomModelParser {
     private static final AtomicLong longVal = new AtomicLong(1);
-    static final String IN_AREA_PREFIX = "in_area_";
+    static final String IN_AREA_PREFIX = "in_";
     private static final Set<String> allowedNames = new HashSet<>(Arrays.asList("edge", "Math"));
     private static final boolean JANINO_DEBUG = Boolean.getBoolean(Scanner.SYSTEM_PROPERTY_SOURCE_DEBUGGING_ENABLE);
     private static final String SCRIPT_FILE_DIR = System.getProperty(Scanner.SYSTEM_PROPERTY_SOURCE_DEBUGGING_DIR, "./src/main/java/com/graphhopper/routing/weighting/custom");
diff --git a/docs/core/profiles.md b/docs/core/profiles.md
index 7b9838e98..aac22b4dd 100644
--- a/docs/core/profiles.md
+++ b/docs/core/profiles.md
@@ -136,14 +136,21 @@ And there are others that take on a numeric value, like:
 *Important note: Whenever you want to use any of these categories for a custom profile you need to add them to
 `graph.encoded_values` in `config.yml`.*
 
-### Creating the `custom_model_file`: Setting up a custom model
+### Setting up a custom model
 
 As we saw in one of the previous sections, the custom weighting function has three parameters that you can adjust:
 speed, priority and distance_influence. You can set up rules that determine these parameters from the edge's properties.
-A set of such rules is called a 'custom model' and it is written in a dedicated YAML or JSON file,
-the `custom_model_file`. We will explain how to set up these rules. Customizing speed and priority is very similar, so
-we will explain most of the details you need to know for speed, but they can be applied very much the same way for
-priority as well.
+A set of such rules is called a 'custom model' and it is written in the YAML language. If you are not familiar with
+YAML don't panic. Just make sure you know the very basic concepts like strings, numbers, lists and objects and you 
+should know enough to understand and use the custom model syntax. But be warned, YAML uses indentation to separate objects,
+lists etc. from each other and whitespace is an important part of the syntax.
+
+In the following we will explain how to set up the custom model rules. The rules for speed and priority are very 
+similar, so in our examples we will first concentrate on the speed rules, but as you will see they can be applied very
+much the same way for priority as well.
+
+The custom model can be entered in the custom model box of the web UI or for server configuration it can either be written
+within the routing profiles or a dedicated file that is referenced from the routing profiles (`custom_model_file`).
 
 #### The custom model language by the example of customizing `speed`
 
@@ -321,35 +328,41 @@ to the `areas` section of in the custom model file. You can then use the name of
 the `if/else/else_if` statements we saw in the previous sections.
 
 In the following example we multiply the speed of all edges in an area called `custom1` with `0.7` and also limit it
-to `50km/h`. Note that each area's name needs to be prefixed with `in_area_`:
+to `50km/h`. Note that each area's name needs to be prefixed with `in_`:
 
 ```yaml
 speed:
-  - if: in_area_custom1
+  - if: in_custom1
     multiply_by: 0.7
-  - if: in_area_custom1
+  - if: in_custom1
     limit_to: 50
 
 areas:
   custom1:
     type: "Feature"
-    # the following 'id' entry will be ignored and a 'properties' entry must be avoided or empty
-    id: "something"
+    id: "something" # optional, but will be ignored
+    properties: { } # optional, but will be ignored
     geometry:
       type: "Polygon"
-      coordinates: [
-        [ 10.75, 46.65 ],
-        [ 9.54, 45.65 ],
-        [ 10.75, 44.65 ],
-        [8.75, 44.65],
-        [8.75, 45.65],
-        [8.75, 46.65]
-      ]
+      coordinates: [ [
+        [ 1.525, 42.511 ],
+        [ 1.510, 42.503 ],
+        [ 1.531, 42.495 ],
+        [ 1.542, 42.505 ],
+        [ 1.525, 42.511 ]
+      ] ]
 ```
 
-The areas are given in GeoJson format, but currently only Polygons are allowed. Note that JSON can be directly copied
-into YAML without further modifications. Using the `areas` feature you can also block entire areas i.e. by multiplying
-the speed with `0`, but for this you should rather use the `priority` section that we will explain next.
+Areas are given in GeoJson format, but currently only the exact format in the above example is supported, i.e. one
+object with type `Feature`, a geometry with type `Polygon` and optional id and properties fields. Note that the
+coordinates array of `Polygon` is an array of arrays that each must describe a closed ring, i.e. the first point must be
+equal to the last. Each point is given as an array [longitude, latitude], so the coordinates array has three dimensions
+total.
+
+Since YAML is a superset of JSON you can also paste GeoJSON to the `areas` section directly.
+
+Using the `areas` feature you can also block entire areas i.e. by multiplying the speed with `0`, but for this you
+should rather use the `priority` section that we will explain next.
 
 #### Customizing `priority`
 
@@ -397,7 +410,7 @@ same way:
 
 ```yaml
 priority:
-  - if: in_area_custom1
+  - if: in_custom1
     multiply_by: 0.7
 ```
 
@@ -406,7 +419,7 @@ in an area like this:
 
 ```yaml
 priority:
-  - if: road_class == MOTORWAY && in_area_custom1
+  - if: road_class == MOTORWAY && in_custom1
     multiply_by: 0.1
 ```
 
diff --git a/docs/core/quickstart-from-source.md b/docs/core/quickstart-from-source.md
index 6658735d8..13a1aa5f9 100644
--- a/docs/core/quickstart-from-source.md
+++ b/docs/core/quickstart-from-source.md
@@ -96,29 +96,17 @@ as those versions are not in maven central:
 
 ### Web UI (JavaScript)
 
-To setup the JavaScript development environment install the [node package
-manager](https://github.com/nvm-sh/nvm#install--update-script). For windows use [nvm-windows](https://github.com/coreybutler/nvm-windows).
-
-To develop the web UI you need to rebuild the bundled main.js on every change. npm does this for you automatically:
-
-```bash
-cd web-bundle
-npm run watch
-```
+To setup the JavaScript development environment you
 
-You also need to build the custom-model-editor
-
-```bash
-cd web-bundle/src/main/js/custom-model-editor
-npm run build
-# ... or in case you want to update the editor itself you can also build it on every change using
-# npm run watch
-```
+1. install the [node package
+manager](https://github.com/nvm-sh/nvm#install--update-script). For windows use [nvm-windows](https://github.com/coreybutler/nvm-windows).
+3. Build the custom-model-editor component: `sh -c "cd web-bundle/src/main/js/custom-model-editor && npm install && npm run build"`
+2. Build the Web UI: `cd web-bundle && npm install && npm run bundle`
 
-To see your changes in the browser without restarting the server you can either run the GH server in debug mode from
-IntelliJ (use `Run->Debugging Actions->Reload Changed Classes` and refresh your browser window). 
+Now the main.js file was created and you should be able to see GraphHopper Maps.
 
-Or you start a separate server. For this you need to change the routing.host property in src/main/resources/com/graphhopper/maps/js/config/options.js:
+To see all changes in the browser without restarting the server you can either run the GH server in debug mode from
+IntelliJ (use `Run->Debugging Actions->Reload Changed Classes` and refresh your browser window) or you start a separate server. To start a separate server for the Web UI you change the routing.host property in src/main/resources/com/graphhopper/maps/js/config/options.js:
 ```js
 ...
   routing: {host: 'http://localhost:8989', api_key: ''},
@@ -132,7 +120,7 @@ npm install -g live-server
 live-server --open=src/main/resources/com/graphhopper/maps/
 ```
 
-Other npm commands e.g. to produce a bundled main.js for production:
+The following npm commands are available in the `web-bundle` directory:
 
 ```bash
 # bundle creates the main file
diff --git a/web-bundle/src/main/js/custom-model-editor/src/custom_model_editor.js b/web-bundle/src/main/js/custom-model-editor/src/custom_model_editor.js
index 525aad141..cef6c2f47 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/custom_model_editor.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/custom_model_editor.js
@@ -1,12 +1,14 @@
 import CodeMirror from "codemirror";
 import "codemirror/mode/yaml/yaml";
 import "codemirror/mode/javascript/javascript";
+import "codemirror/addon/edit/matchbrackets";
 import "codemirror/addon/hint/show-hint";
 import "codemirror/addon/lint/lint";
 import YAML from "yaml";
 import {validate} from "./validate.js";
 import {complete} from "./complete.js";
 import {parse} from "./parse.js";
+import {completeYaml} from "./yaml_complete";
 
 
 class CustomModelEditor {
@@ -27,6 +29,7 @@ class CustomModelEditor {
 
         this.cm = CodeMirror(callback, {
             lineNumbers: true,
+            matchBrackets: true,
             mode: "yaml",
             extraKeys: {
                 'Ctrl-Space': this.showAutoCompleteSuggestions
@@ -40,8 +43,8 @@ class CustomModelEditor {
         this.cm.on("cursorActivity", (e) => {
             if (!this._yaml)
                 return;
-            // in case the auto-complete popup is active already we update it (this allows filtering values while typing
-            // with an open popup)
+            // we update the auto complete window to allows filtering values while typing with an open popup)
+            this.cm.closeHint();
             if (this.cm.state.completionActive) {
                 this.showAutoCompleteSuggestions();
             }
@@ -153,22 +156,40 @@ class CustomModelEditor {
     showAutoCompleteSuggestions = () => {
         const validateResult = validate(this.cm.getValue());
         const cursor = this.cm.indexFromPos(this.cm.getCursor());
-        validateResult.conditionRanges
-            .map(cr => {
-                const condition = this.cm.getValue().substring(cr[0], cr[1]);
-                const offset = cr[0];
-                // note that we allow the cursor to be at the end (inclusive!) of the range
-                if (cursor >= offset && cursor <= cr[1]) {
-                    const completeRes = complete(condition, cursor - offset, this._categories, validateResult.areas);
-                    if (completeRes.suggestions.length > 0) {
-                        const range = [
-                            this.cm.posFromIndex(completeRes.range[0] + offset),
-                            this.cm.posFromIndex(completeRes.range[1] + offset),
-                        ];
-                        this._suggest(range, completeRes.suggestions);
-                    }
+        const completeRes = completeYaml(this.cm.getValue(), cursor);
+        if (completeRes.suggestions.length > 0) {
+            if (completeRes.suggestions.length === 1 && completeRes.suggestions[0] === `__hint__type a condition`) {
+                // if the yaml completion suggests entering a condition we run the condition completion on the found
+                // condition range instead
+                const condition = this.cm.getValue().substring(completeRes.range[0], completeRes.range[1]);
+                const offset = completeRes.range[0];
+                const completeConditionRes = complete(condition, cursor - offset, this._categories, validateResult.areas);
+                if (completeConditionRes.suggestions.length > 0) {
+                    const range = [
+                        this.cm.posFromIndex(completeConditionRes.range[0] + offset),
+                        this.cm.posFromIndex(completeConditionRes.range[1] + offset)
+                    ];
+                    this._suggest(range, completeConditionRes.suggestions);
                 }
-            });
+            } else {
+                // limit the replacement range to the current line and do not include the new line character at the
+                // end of the line. otherwise auto-complete messes up the following lines.
+                const currLineStart = this.cm.indexFromPos({line: this.cm.getCursor().line, ch: 0});
+                const currLineEnd = this.cm.indexFromPos({line: this.cm.getCursor().line + 1, ch: 0});
+                const start = Math.max(currLineStart, completeRes.range[0]);
+                let stop = Math.min(currLineEnd, completeRes.range[1]);
+                if (stop > start && /\r\n|\r|\n/g.test(this.cm.getValue()[stop - 1]))
+                    stop--;
+                const range = [
+                    this.cm.posFromIndex(start),
+                    this.cm.posFromIndex(stop)
+                ];
+                // filter suggestions based on existing value
+                const suggestions = completeRes.suggestions.filter(s =>
+                    startsWith(s, this.cm.getValue().substring(start, stop).trim()));
+                this._suggest(range, suggestions);
+            }
+        }
     }
 
     _suggest = (range, suggestions) => {
diff --git a/web-bundle/src/main/js/custom-model-editor/src/parse.js b/web-bundle/src/main/js/custom-model-editor/src/parse.js
index 2012d113f..4a178f920 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/parse.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/parse.js
@@ -41,7 +41,7 @@ function parse(expression, categories, areas) {
  *
  * expression -> comparison (logicOperator comparison)*
  * comparison -> enumCategory comparator value | numericCategory numericComparator number | boolean | booleanCategory |
- *               booleanCategory comparator boolean | 'in_area_' area | 'in_area_' area comparator boolean | value'(' expression ')'
+ *               booleanCategory comparator boolean | 'in_' area | 'in_' area comparator boolean | value'(' expression ')'
  * logicOperator -> '&&' | '||'
  * comparator -> '==' | '!='
  * numericComparator -> '>' | '<' | '>=' | '<=' | '==' | '!='
@@ -126,7 +126,7 @@ function parseComparison() {
     } else if (isInvalidAreaOperator()) {
         return parseInvalidAreaOperator();
     } else {
-        return error(`unexpected token '${_tokens[_idx]}'`, [_idx, _idx + 1], Object.keys(_categories).concat(_areas.map(a => 'in_area_' + a)).concat(['true', 'false']));
+        return error(`unexpected token '${_tokens[_idx]}'`, [_idx, _idx + 1], Object.keys(_categories).concat(_areas.map(a => 'in_' + a)).concat(['true', 'false']));
     }
 }
 
@@ -174,16 +174,16 @@ function parseBooleanComparison() {
 
 function parseArea() {
     const token = _tokens[_idx];
-    if (token.length < `in_area_`.length) {
-        console.error(`expected something like 'in_area_xyz', but got: '${token}'`);
+    if (token.length < `in_`.length) {
+        console.error(`expected something like 'in_xyz', but got: '${token}'`);
         return;
     }
-    const area = token.substring(`in_area_`.length)
+    const area = token.substring(`in_`.length)
     if (_areas.indexOf(area) < 0) {
-        return error(`unknown area: '${area}'`, [_idx, _idx + 1], _areas.map(a => 'in_area_' + a));
+        return error(`unknown area: '${area}'`, [_idx, _idx + 1], _areas.map(a => 'in_' + a));
     }
 
-    // rule: comparison -> 'in_area_' area
+    // rule: comparison -> 'in_' area
     if (_idx + 1 === _tokens.length) {
         _idx++;
         return valid();
@@ -191,7 +191,7 @@ function parseArea() {
         _idx++;
         return valid();
     }
-    // rule: comparison -> 'in_area_' area comparator boolean
+    // rule: comparison -> 'in_' area comparator boolean
     return parseTripleComparison(
         comparisonOperators,
         (category, operator, value) => isBoolean(value),
@@ -201,11 +201,11 @@ function parseArea() {
 
 function parseInvalidAreaOperator() {
     const token = _tokens[_idx];
-    if (token.substring(0, 8) === `in_area_`) {
+    if (token.substring(0, 3) === `in_`) {
         console.error(`${token} is a valid area operator and should have been detected earlier`);
         return;
     }
-    return error(`area names must be prefixed with 'in_area_'`, [_idx, _idx + 1], _areas.map(a => 'in_area_' + a));
+    return error(`area names must be prefixed with 'in_'`, [_idx, _idx + 1], _areas.map(a => 'in_' + a));
 }
 
 function parseTripleComparison(allowedComparators, isValid, getAllowedValues) {
@@ -272,13 +272,13 @@ function isBooleanLiteral() {
 
 function isArea() {
     const token = _tokens[_idx];
-    return typeof token === 'string' && token.substr(0, 8) === 'in_area_';
+    return typeof token === 'string' && token.substr(0, 3) === 'in_';
 }
 
 function isInvalidAreaOperator() {
     const token = _tokens[_idx];
-    // typing something like in_area might be a common error so we provide some support for it
-    return typeof token === 'string' && (token.substr(0, 3) === 'in_' || _areas.indexOf(token) >= 0);
+    // typing something like 'area12' might be a common error so we provide some support for it
+    return typeof token === 'string' && _areas.indexOf(token) >= 0;
 }
 
 function isCategory() {
diff --git a/web-bundle/src/main/js/custom-model-editor/src/validate.js b/web-bundle/src/main/js/custom-model-editor/src/validate.js
index ed3dfeed5..6edde5eec 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/validate.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/validate.js
@@ -1,15 +1,9 @@
 import YAML from 'yaml';
 
 const rootKeys = ['speed', 'priority', 'distance_influence', 'areas'];
-const rootKeysString = `['speed', 'priority', 'distance_influence', 'areas']`;
-
 const clauses = ['if', 'else_if', 'else'];
-const clausesString = `['if', 'else_if', 'else']`;
 const operators = ['multiply_by', 'limit_to'];
-const operatorsString = `['multiply_by', 'limit_to']`;
-
 const statementKeys = ['if', 'else_if', 'else', 'multiply_by', 'limit_to'];
-const statementKeysString = `['if', 'else_if', 'else', 'multiply_by', 'limit_to']`;
 
 let _conditionRanges = [];
 let _areas = [];
@@ -75,87 +69,37 @@ export function validate(yaml) {
 function validateYamlDoc(doc) {
     // empty docs are ok
     if (!doc.contents) return [];
-
-    // root must be a yaml object
-    if (!isYamlObject(doc.contents.type)) {
-        return [
-            error(`root`, `must be an object. possible keys: ${rootKeysString}. given type: ${displayType(doc.contents)}`, doc.contents.range)
-        ]
-    }
-
-    // root elements must be strings with certain values
-    {
-        const errors = validateRootKeys(doc.contents);
-        if (errors.length > 0) return errors;
-    }
-
-    // root elements must have certain types
-    {
-        const errors = validateRootValues(doc.contents.items);
-        if (errors.length > 0) return errors;
-    }
-
-    // document is valid
-    return [];
+    return validateRoot(doc.contents);
 }
 
-function validateRootKeys(rootObj) {
-    return validateObjectKeys('root', rootKeys, rootKeysString, rootObj, rootKeys.length);
+function validateRoot(root) {
+    const message = `possible keys: ${displayList(rootKeys)}`;
+    const keyIsValid = (key) => rootKeys.indexOf(key.value) >= 0;
+    return validateObject('root', root, keyIsValid, (path, keys, range) => [], message, validateRootKeyValuePair);
 }
 
-function validateRootValues(rootItems) {
-    const errors = [];
-    for (let i = 0; i < rootItems.length; ++i) {
-        const key = rootItems[i].key.value;
-        const value = rootItems[i].value;
-        if (value === null) {
-            errors.push(error(`${key}`, `must not be null`, rootItems[i].key.range));
-        } else {
-            if (key === 'speed' || key === 'priority') {
-                if (!isYamlList(value.type)) {
-                    errors.push(error(`${key}`, `must be a list. given type: ${displayType(value)}`, value.range));
-                } else {
-                    errors.push.apply(errors, validateStatements(key, value));
-                }
-            } else if (key === 'distance_influence') {
-                if (!isYamlPlain(value.type))
-                    errors.push(error(`${key}`, `must be a number. given type: ${displayType(value)}`, value.range));
-                else if (!isNumber(value))
-                    errors.push(error(`${key}`, `must be a number. given: '${value.value}'`, value.range));
-            } else if (key === 'areas') {
-                if (!isYamlObject(value.type)) {
-                    errors.push(error(`${key}`, `must be an object. given type: ${displayType(value)}`, value.range));
-                } else {
-                    errors.push.apply(errors, validateAreas(value));
-                }
-            } else {
-                console.error(`Unexpected root key ${key}`);
-            }
-        }
+function validateRootKeyValuePair(path, key, value) {
+    if (value === null || value.value === null) {
+        return [error(`${key.value}`, `must not be null`, key.range)];
+    } else if (key.value === 'speed' || key.value === 'priority') {
+        return validateStatements(key.value, value);
+    } else if (key.value === 'distance_influence') {
+        return validateDistanceInfluence(value);
+    } else if (key.value === 'areas') {
+        return validateAreas(value);
+    } else {
+        console.error(`Unexpected root key ${key.value}`);
+        return [];
     }
-    return errors;
 }
 
 function validateStatements(key, itemsObj) {
-    const errors = [];
-    const items = itemsObj.items;
-    for (let i = 0; i < items.length; ++i) {
-        const item = items[i];
-        if (item === null) {
-            const range = itemsObj.cstNode.items[i].range;
-            errors.push(error(`${key}[${i}]`, `every statement must be an object with a clause ${clausesString} and an operator ${operatorsString}. given type: null`, [range.start, range.end]));
-        } else if (isYamlPair(item.type)) {
-            errors.push(error(`${key}[${i}]`, `every statement must be an object with a clause ${clausesString} and an operator ${operatorsString}. given type: ${displayType(item)}`, [item.key.range[0], item.value.range[1]]));
-        } else if (!isYamlObject(item.type))
-            errors.push(error(`${key}[${i}]`, `every statement must be an object with a clause ${clausesString} and an operator ${operatorsString}. given type: ${displayType(item)}`, item.range));
-        else
-            errors.push.apply(errors, validateStatement(key, i, item));
-    }
-
+    const errors = validateList(key, itemsObj, 0, -1, validateStatement);
     if (errors.length > 0)
         return errors;
 
-    // statements seem to be ok, but are they in the right order?
+    // single statements seem to be ok, but are they written in the right order?
+    const items = itemsObj.items;
     const clausesList = [];
     for (let i = 0; i < items.length; ++i) {
         for (let j = 0; j < items[i].items.length; ++j) {
@@ -173,110 +117,272 @@ function validateStatements(key, itemsObj) {
     return errors;
 }
 
-function validateStatement(statementKey, statementIndex, statementItem) {
-    const statementEntries = statementItem.items;
-    const errors = validateObjectKeys(`${statementKey}[${statementIndex}]`, statementKeys, statementKeysString, statementItem, 2);
-    if (errors.length > 0) return errors;
+function validateStatement(path, statementItem, statementIndex) {
+    const message = `possible keys: ${displayList(statementKeys)}`;
+    const keyIsValid = (key) => statementKeys.indexOf(key.value) >= 0;
+    return validateObject(path, statementItem,
+        keyIsValid, validateStatementKeyConstraints, message, validateStatementValue);
+}
 
-    let hasClause = false;
-    let hasOperator = false;
-    const keys = [];
-    for (let i = 0; i < statementEntries.length; ++i) {
-        const entry = statementEntries[i];
-        const key = entry.key.value;
-        keys.push(key);
-        const isClause = clauses.indexOf(key) >= 0;
-        const isOperator = operators.indexOf(key) >= 0;
-        if (isClause) {
-            hasClause = true;
-            if (key === 'else') {
-                if (entry.value !== null) {
-                    errors.push(error(`${statementKey}[${statementIndex}]`, `the value of 'else' must be null. given: '${entry.value}'`, entry.value.range));
-                }
-            } else {
-                if (entry.value === null || entry.value.value === null) {
-                    errors.push(error(`${statementKey}[${statementIndex}]`, `the value of '${key}' must be a string or boolean. given type: null`, entry.key.range));
-                    // this is a very common case (we typed 'if: ' and the value is still null). unfortunately we cannot reliably
-                    // obtain the value range, not even from the cst(?!). So we do this workaround and only calculate
-                    // the range based on the key range, see this: https://github.com/eemeli/yaml/discussions/231
-                    _conditionRanges.push([entry.key.range[1] + 1, entry.key.range[1] + 2]);
-                } else if (!isString(entry.value) && !isBoolean(entry.value)) {
-                    errors.push(error(`${statementKey}[${statementIndex}]`, `the value of '${key}' must be a string or boolean. given type: ${displayType(entry.value)}`, entry.value.range));
-                } else {
-                    _conditionRanges.push(entry.value.range);
-                }
+function validateStatementKeyConstraints(path, keys, range) {
+    const errors = [];
+    if (keys.length > 2)
+        errors.push(error(path, `too many keys. maximum: 2. given: ${keys.sort()}`, range));
+    const hasClause = keys.some(key => clauses.indexOf(key) >= 0);
+    const hasOperator = keys.some(key => operators.indexOf(key) >= 0);
+    if (!hasClause)
+        errors.push(error(path, `every statement must have a clause ${displayList(clauses)}. given: ${keys}`, range));
+    if (!hasOperator)
+        errors.push(error(path, `every statement must have an operator ${displayList(operators)}. given: ${keys}`, range));
+    return errors;
+}
+
+function validateStatementValue(path, key, value) {
+    const errors = [];
+    const isClause = clauses.indexOf(key.value) >= 0;
+    const isOperator = operators.indexOf(key.value) >= 0;
+    if (isClause) {
+        if (key.value === 'else') {
+            if (value !== null && value.value !== null) {
+                errors.push(error(`${path}[else]`, `must be null. given: '${value}'`, value.range));
             }
-        }
-        if (isOperator) {
-            hasOperator = true;
-            if (entry.value === null) {
-                errors.push(error(`${statementKey}[${statementIndex}]`, `the value of '${key}' must be a number. given type: null`, entry.key.range));
-            } else if (!isNumber(entry.value)) {
-                errors.push(error(`${statementKey}[${statementIndex}]`, `the value of '${key}' must be a number. given type: ${displayType(entry.value)}`, entry.value.range));
+        } else {
+            if (value === null || value.value === null) {
+                errors.push(error(`${path}[${key.value}]`, `must be a string or boolean. given type: null`, key.range));
+                // this is a very common case (we typed 'if: ' and the value is still null). unfortunately we cannot reliably
+                // obtain the value range, not even from the cst(?!). So we do this workaround and only calculate
+                // the range based on the key range, see this: https://github.com/eemeli/yaml/discussions/231
+                _conditionRanges.push([key.range[1] + 1, key.range[1] + 2]);
+            } else if (!isString(value) && !isBoolean(value)) {
+                errors.push(error(`${path}[${key.value}]`, `must be a string or boolean. given type: ${displayType(value)}`, value.range));
+            } else {
+                _conditionRanges.push(value.range);
             }
         }
-        if (isClause === isOperator) {
-            console.error(`Unexpected statement: ${isClause} ${isOperator} ${key} ${clauses.indexOf(key)} ${entry}`);
-        }
     }
-    if (!hasClause) {
-        errors.push(error(`${statementKey}[${statementIndex}]`, `every statement must have a clause ${clausesString}. given: ${keys}`, statementItem.range));
+    if (isOperator) {
+        if (value === null) {
+            errors.push(error(`${path}[${key.value}]`, `must be a number. given type: null`, key.range));
+        } else if (!isNumber(value)) {
+            errors.push(error(`${path}[${key.value}]`, `must be a number. given type: ${displayType(value)}`, value.range));
+        }
     }
-    if (!hasOperator) {
-        errors.push(error(`${statementKey}[${statementIndex}]`, `every statement must have an operator ${operatorsString}. given: ${keys}`, statementItem.range));
+    if (isClause === isOperator) {
+        console.error(`Unexpected statement: ${isClause} ${isOperator} ${key} ${value}`);
     }
     return errors;
 }
 
+function validateDistanceInfluence(value) {
+    if (!isYamlPlain(value.type))
+        return [error(`distance_influence`, `must be a number. given type: ${displayType(value)}`, value.range)];
+    else if (!isNumber(value))
+        return [error(`distance_influence`, `must be a number. given: '${value.value}'`, value.range)];
+    else
+        return [];
+}
+
 function validateAreas(areas) {
-    // todo: currently we only check the area names, but do not check their values! this could be a use-case for json
-    // schema validation, because we could use a ready-made geo-json schema. or maybe just not validate it at all...
+    const validateKeys = (path, keys, range) => {
+        _areas = keys;
+        return [];
+    }
+    return validateObject(`areas`, areas,
+        isValidAreaName, validateKeys, `names may only contain a-z, digits and _`, validateArea);
+}
+
+function validateArea(path, areaName, area) {
+    if (area === null || area.value === null)
+        return [error(`${path}[${areaName.value}]`, `must not be null`, areaName.range)];
+
+    const areaKeys = ['type', 'geometry', 'id', 'properties'];
+    const message = `possible keys: ${displayList(areaKeys)}`;
+    const keyIsValid = (key) => areaKeys.indexOf(key.value) >= 0;
+    return validateObject(`${path}[${areaName.value}]`, area,
+        keyIsValid, validateRequiredAreaKeys, message, validateAreaField);
+}
+
+function validateRequiredAreaKeys(path, keys, range) {
+    const requiredAreaKeys = ['type', 'geometry'];
     const errors = [];
-    const keys = new Set();
-    const entries = areas.items;
-    for (let i = 0; i < entries.length; ++i) {
-        const key = entries[i].key;
-        if (key === undefined || key === null) {
-            errors.push(error(`areas`, `keys must not be null`, areas.range));
-        } else if (!isString(key)) {
-            errors.push(error(`areas`, `keys must be strings. given type: ${displayType(key)}`, key.range));
-        } else if (key.value.length === 0) {
-            errors.push(error(`areas`, `keys must not be empty. given: '${key}'`, key.range));
-        } else if (!isValidAreaName(key.value)) {
-            errors.push(error(`areas`, `invalid area name: '${key.value}', only a-z, digits and _ are allowed`, key.range));
-        } else if (keys.has(key.value)) {
-            errors.push(error(`areas`, `keys must be unique. duplicate: '${key.value}'`, key.range))
+    for (let i = 0; i < requiredAreaKeys.length; i++) {
+        if (keys.indexOf(requiredAreaKeys[i]) < 0)
+            errors.push(error(path, `missing '${requiredAreaKeys[i]}'. given: ${displayList(keys)}`, range));
+    }
+    return errors;
+}
+
+function validateAreaField(path, key, value) {
+    if (value === null || value.value === null) {
+        return [error(`${path}[${key.value}]`, `must not be null`, key.range)];
+    } else if (key.value === 'type') {
+        if (value.value !== 'Feature')
+            return [error(`${path}[${key.value}]`, `must be 'Feature'. given: '${value.value}'`, value.range)];
+        else
+            return [];
+    } else if (key.value === 'properties') {
+        return validateObject(`${path}[${key.value}]`, value, () => true, () => [], '', () => []);
+    } else if (key.value === 'id') {
+        if (!isString(value)) {
+            return [error(`${path}[${key.value}]`, `must be a string. given type: ${displayType(value)}`, value.range)];
+        }
+    } else if (key.value === 'geometry') {
+        const geometryKeys = ['type', 'coordinates'];
+        const message = `possible keys: ${displayList(geometryKeys)}`;
+        const keyIsValid = (key) => geometryKeys.indexOf(key.value) >= 0;
+        return validateObject(`${path}[${key.value}]`, value,
+            keyIsValid, validateRequiredGeometryKeys, message, validateGeometryField);
+    } else {
+        console.error(`unexpected area field ${key.value}`);
+        return [];
+    }
+}
+
+function validateRequiredGeometryKeys(path, keys, range) {
+    const requiredKeys = ['type', 'coordinates'];
+    const errors = [];
+    for (let i = 0; i < requiredKeys.length; i++) {
+        if (keys.indexOf(requiredKeys[i]) < 0)
+            errors.push(error(path, `missing '${requiredKeys[i]}'. given: ${displayList(keys)}`, range));
+    }
+    return errors;
+}
+
+function validateGeometryField(path, key, value) {
+    if (value === null || value.value === null) {
+        return [error(`${path}[${key.value}]`, `must not be null`, key.range)];
+    } else if (key.value === 'type') {
+        if (value.value !== 'Polygon')
+            return [error(`${path}[${key.value}]`, `must be 'Polygon'. given: '${value.value}'`, value.range)];
+        else
+            return [];
+    } else if (key.value === 'coordinates') {
+        return validateCoordinates(`${path}[${key.value}]`, key, value);
+    } else {
+        console.error(`unexpected area field ${key.value}`);
+        return [];
+    }
+}
+
+function validateCoordinates(path, key, value) {
+    return validateList(path, value, 1, -1, validateLinearRing);
+}
+
+function validateList(path, node, minLength, maxLength, validateListItem) {
+    if (!isYamlList(node.type))
+        return [error(path, `must be a list. given type: ${displayType(node)}`, node.range)];
+
+    const items = node.items;
+    if (items.length < minLength)
+        return [error(path, `minimum length: ${minLength}, given: ${items.length}`, node.range)];
+    if (maxLength >= 0 && items.length > maxLength)
+        return [error(path, `maximum length: ${maxLength}, given: ${items.length}`, node.range)];
+
+    const errors = [];
+    for (let i = 0; i < items.length; ++i) {
+        const item = items[i];
+        if (item === null) {
+            const range = node.cstNode.items[i].range;
+            errors.push(error(`${path}[${i}]`, `must not be null`, [range.start, range.end]));
+        } else if (isYamlPair(item.type)) {
+            // this is a bit ugly. we do not reliably get useful range information, e.g. for nested pairs
+            // so we fallback to the range of the entire node. maybe related: https://github.com/eemeli/yaml/discussions/231
+            errors.push(error(`${path}`, `must not contain pairs`, node.range));
+            break;
         } else {
-            keys.add(key.value);
-            _areas.push(key.value);
+            errors.push.apply(errors, validateListItem(`${path}[${i}]`, item, i));
         }
     }
     return errors;
 }
 
+function validateLinearRing(path, ring, index) {
+    const errors = validateList(path, ring, 4, -1, validatePoint);
+    if (errors.length > 0)
+        return errors;
+    const firstPoint = ring.items[0];
+    const lastPoint = ring.items[ring.items.length - 1];
+    if (!pointsEqual(firstPoint, lastPoint))
+        return [error(`${path}`, `the last point must be equal to the first`, lastPoint.range)];
+    else
+        return [];
+}
+
+function validatePoint(path, point, index) {
+    return validateList(path, point, 2, 2, validateCoordinate);
+}
+
+function validateCoordinate(path, coordinate, index) {
+    const errors = [];
+    if (!isNumber(coordinate)) {
+        errors.push(error(path, `must be a number`, coordinate.range));
+    } else {
+        if (index === 0 && (coordinate.value < -180 || coordinate.value > 180))
+            errors.push(error(`${path}`, `longitude must be in [-180, +180]`, coordinate.range));
+        if (index === 1 && (coordinate.value < -90 || coordinate.value > 90))
+            errors.push(error(`${path}`, `latitude must be in [-90, +90]`, coordinate.range));
+    }
+    return errors;
+}
+
+function pointsEqual(p, q) {
+    return p.items[0].value === q.items[0].value && p.items[1].value === q.items[1].value;
+}
+
 function isValidAreaName(string) {
     const regex = /^[a-z][0-9A-Za-z_]*$/g
     return regex.test(string);
 }
 
-function validateObjectKeys(objectKey, legalKeys, legalKeysString, obj, maxKeys) {
+function validateObject(path, obj, keyIsValid, validateKeys, message, validateKeyValuePair) {
+    if (obj.value === null)
+        return [error(path, `must not be null`, obj.range)];
+    if (!isYamlObject(obj.type))
+        return [error(path, `must be an object. given type: ${displayType(obj)}`, obj.range)];
+
+    const keyValidation = validateObjectKeys(path, keyIsValid, message, obj);
+    if (keyValidation.errors.length > 0) return keyValidation.errors;
+
+    const errors = [];
+    errors.push.apply(errors, validateKeys(path, keyValidation.keys, obj.range));
+    if (errors.length > 0) return errors;
+
+    errors.push.apply(errors, validateObjectItems(path, obj.items, validateKeyValuePair));
+    return errors;
+}
+
+function validateObjectKeys(objectKey, keyIsValid, message, obj) {
     const errors = [];
     const keys = new Set();
     const entries = obj.items;
     for (let i = 0; i < entries.length; ++i) {
         const key = entries[i].key;
         if (key === undefined || key === null) {
-            errors.push(error(`${objectKey}`, `possible keys: ${legalKeysString}. given: ${key}`, obj.range));
-        } else if (!isString(key) || key.value.length === 0 || key.value.trim().length === 0 || legalKeys.indexOf(key.value) < 0) {
-            errors.push(error(`${objectKey}`, `possible keys: ${legalKeysString}. given: '${key}'`, key.range));
+            errors.push(error(objectKey, `keys must not be null`, obj.range));
+        } else if (!isString(key)) {
+            errors.push(error(objectKey, `keys must be strings. given type: ${displayType(key)}`, key.range));
+        } else if (key.value.length === 0 || key.value.trim().length === 0) {
+            errors.push(error(objectKey, `keys must be non-empty and must not only consist of whitespace. given: '${key}'`, key.range));
+        } else if (!keyIsValid(key)) {
+            errors.push(error(objectKey, `${message}. given: '${key}'`, key.range));
         } else if (keys.has(key.value)) {
-            errors.push(error(`${objectKey}`, `keys must be unique. duplicate: '${key.value}'`, key.range))
+            errors.push(error(objectKey, `keys must be unique. duplicate: '${key.value}'`, key.range))
         } else {
             keys.add(key.value);
         }
     }
-    if (keys.size > maxKeys) {
-        errors.push(error(`${objectKey}`, `too many keys. maximum: ${maxKeys}. given: ${Array.from(keys).sort()}`, obj.range));
+    return {
+        errors,
+        keys: Array.from(keys)
+    };
+}
+
+function validateObjectItems(path, items, validateKeyValuePair) {
+    const errors = [];
+    for (let i = 0; i < items.length; ++i) {
+        const key = items[i].key;
+        const value = items[i].value;
+        errors.push.apply(errors, validateKeyValuePair(path, key, value));
     }
     return errors;
 }
@@ -324,6 +430,11 @@ function displayType(node) {
     }
 }
 
+function displayList(list) {
+    const str = list.map(l => `'${l}'`).join(', ');
+    return `[${str}]`;
+}
+
 function isString(node) {
     return isYamlString(node.type) && typeof node.value === 'string';
 }
diff --git a/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.js b/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.js
new file mode 100644
index 000000000..3abaa9671
--- /dev/null
+++ b/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.js
@@ -0,0 +1,180 @@
+import YAML from "yaml";
+
+/**
+ * Returns auto-complete suggestions for a yaml string and a given character position
+ */
+export function completeYaml(content, pos) {
+    // if the cursor is not positioned on top of a token we insert a dummy character
+    const yamlString = (pos >= content.length || (isWhitespace(content[pos]) && (pos === 0 || content[pos - 1] !== ':')))
+        ? content.substring(0, pos) + '…' + content.substring(pos)
+        : content;
+    const yamlPath = getYamlPath(yamlString, pos);
+
+    // remove the last element (the actual node) and only consider the ancestors
+    const ancestorSignature = yamlPath.signature;
+    ancestorSignature.pop();
+    const signatureString = ancestorSignature.join('-');
+    const ancestorPath = yamlPath.path;
+    ancestorPath.pop();
+    if (
+        // the signature of root elements is root-x or root-map-pair-x
+        /^root$/.test(signatureString) ||
+        /^root-map-pair$/.test(signatureString)
+    ) {
+        const suggestions = ['speed', 'priority', 'distance_influence', 'areas']
+            .filter(s => ancestorPath.length === 1 || !keyAlreadyExistsInOtherPairs(ancestorPath[1].items, ancestorPath[2], s));
+        return {
+            suggestions,
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[distance_influence]$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`__hint__type a number`],
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        // the signature of statements is root-map-pair[speed/priority]-list[i]-x or root-map-pair[speed/priority]-list[i]-map-pair-x
+        /^root-map-pair\[(speed|priority)]-list\[[0-9]+]$/.test(signatureString) ||
+        /^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair$/.test(signatureString)
+    ) {
+        const clauses = [`if`, `else_if`, `else`];
+        const operators = [`limit_to`, `multiply_by`];
+        const hasClause = ancestorPath.length === 6 && keysAlreadyExistInOtherPairs(ancestorPath[4].items, ancestorPath[5], clauses);
+        const hasOperator = ancestorPath.length === 6 && keysAlreadyExistInOtherPairs(ancestorPath[4].items, ancestorPath[5], operators);
+        let suggestions = [];
+        if (!hasClause)
+            suggestions.push(...clauses);
+        if (!hasOperator)
+            suggestions.push(...operators);
+        return {
+            suggestions,
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair\[(if|else_if|else)]$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`__hint__type a condition`],
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[(speed|priority)]-list\[[0-9]+]-map-pair\[(limit_to|multiply_by)]$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`__hint__type a number`],
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[areas]$/.test(signatureString) ||
+        /^root-map-pair\[areas]-map-pair$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`__hint__type an area name`],
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]$/.test(signatureString) ||
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair$/.test(signatureString)
+    ) {
+        const suggestions = [`geometry`, `type`]
+            .filter(s => !(ancestorPath.length === 7 && keyAlreadyExistsInOtherPairs(ancestorPath[5].items, ancestorPath[6], s)));
+        return {
+            suggestions,
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[type]$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`Feature`],
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]$/.test(signatureString) ||
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]-map-pair$/.test(signatureString)
+    ) {
+        const suggestions = [`type`, `coordinates`]
+            .filter(s => !(ancestorPath.length === 9 && keyAlreadyExistsInOtherPairs(ancestorPath[7].items, ancestorPath[8], s)));
+        return {
+            suggestions,
+            range: yamlPath.tokenRange
+        }
+    } else if (
+        /^root-map-pair\[areas]-map-pair\[[a-zA-Z0-9_]*]-map-pair\[geometry]-map-pair\[type]$/.test(signatureString)
+    ) {
+        return {
+            suggestions: [`Polygon`],
+            range: yamlPath.tokenRange
+        }
+    } else {
+        return {
+            suggestions: [],
+            range: []
+        }
+    }
+}
+
+/**
+ * Returns the YAML path and a special string representation (the 'signature') for the given yaml string and position.
+ * The returned object contains the path as array, its 'signature' as string array and the token range of the token at
+ * pos.
+ */
+export function getYamlPath(content, pos) {
+    const doc = YAML.parseDocument(content);
+    const result = {
+        path: [],
+        signature: [],
+        tokenRange: []
+    };
+    YAML.visit(doc, {
+        Scalar(key, node, path) {
+            // we use the end of the range inclusively!
+            if (pos >= node.range[0] && pos <= node.range[1]) {
+                result.path = path.concat([node]);
+                result.signature = path.map((n, i) => nodeToPathElement(n, i + 1 < path.length ? path[i + 1] : node));
+                result.signature.push(nodeToPathElement(node, null));
+                result.tokenRange = node.range;
+                return YAML.visit.BREAK;
+            }
+        }
+    });
+    return result;
+}
+
+function nodeToPathElement(node, child) {
+    if (node.type === 'DOCUMENT') {
+        return 'root';
+    } else if (node.type === 'MAP' || node.type === 'FLOW_MAP') {
+        return 'map';
+    } else if (node.type === 'PAIR') {
+        if (child !== null && node.value === child) {
+            return `pair[${node.key ? node.key.value : node.key}]`
+        } else {
+            return `pair`;
+        }
+    } else if (node.type === 'SEQ' || node.type === 'FLOW_SEQ') {
+        if (child !== null) {
+            return `list[${node.items.indexOf(child)}]`;
+        } else {
+            return `list`;
+        }
+    } else if (node.type === 'PLAIN') {
+        return node.value;
+    } else {
+        return `unknown[${node.type}]`;
+    }
+}
+
+function keyAlreadyExistsInOtherPairs(allPairs, thisPair, key) {
+    return allPairs.some(p => p !== thisPair && p.key && p.key.value === key);
+}
+
+function keysAlreadyExistInOtherPairs(allPairs, thisPair, keys) {
+    return allPairs.some(p => p !== thisPair && p.key && keys.indexOf(p.key.value) >= 0);
+}
+
+function isWhitespace(str) {
+    return str.trim() === '';
+}
\ No newline at end of file
diff --git a/web-bundle/src/main/resources/com/graphhopper/maps/index.html b/web-bundle/src/main/resources/com/graphhopper/maps/index.html
index 5c63ef889..141df6a0a 100644
--- a/web-bundle/src/main/resources/com/graphhopper/maps/index.html
+++ b/web-bundle/src/main/resources/com/graphhopper/maps/index.html
@@ -72,7 +72,7 @@
             <div id="custom-model-editor"></div>
             <div style="overflow: hidden">
                 <div id="custom-model-util-buttons" class="small_text" style="float: left; padding-right: 10px">
-                    <a id="custom-model-docs" href="https://github.com/graphhopper/graphhopper/blob/master/docs/core/profiles.md#custom-weighting">Docs</a> |
+                    <a id="custom-model-docs" href="https://github.com/graphhopper/graphhopper/blob/master/docs/core/profiles.md#setting-up-a-custom-model">Help</a> |
                     <button id="custom-model-example">Example</button> |
                     <button id="custom-model-toggle"></button>
                 </div>
diff --git a/web-bundle/src/main/resources/com/graphhopper/maps/js/main-template.js b/web-bundle/src/main/resources/com/graphhopper/maps/js/main-template.js
index 473126089..eae328d8f 100644
--- a/web-bundle/src/main/resources/com/graphhopper/maps/js/main-template.js
+++ b/web-bundle/src/main/resources/com/graphhopper/maps/js/main-template.js
@@ -100,19 +100,18 @@ $(document).ready(function (e) {
     });
     function showCustomModelExample() {
         cmEditor.value =
-            "# Ctrl+Space shows suggestions"
-            + "\n# for if-conditions"
+            "# Press Ctrl+Space for suggestions"
             + "\nspeed:"
-            + "\n- if: road_class == MOTORWAY"
-            + "\n  multiply_by: 0.8"
+            + "\n - if: road_class == MOTORWAY"
+            + "\n   multiply_by: 0.8"
             + "\n"
             + "\npriority:"
-            + "\n- if: road_environment == TUNNEL"
-            + "\n  multiply_by: 0.0"
-            + "\n- if: road_class == RESIDENTIAL"
-            + "\n  multiply_by: 0.7"
-            + "\n- if: max_weight < 3"
-            + "\n  multiply_by: 0.0"
+            + "\n - if: road_environment == TUNNEL"
+            + "\n   multiply_by: 0.0"
+            + "\n - if: road_class == RESIDENTIAL"
+            + "\n   multiply_by: 0.7"
+            + "\n - if: max_weight < 3"
+            + "\n   multiply_by: 0.0"
             + "\n";
         cmEditor.cm.focus();
         cmEditor.cm.setCursor(0);


Test patch:
diff --git a/core/src/test/java/com/graphhopper/routing/util/CarFlagEncoderTest.java b/core/src/test/java/com/graphhopper/routing/util/CarFlagEncoderTest.java
index 260150ff3..dc18bb87b 100644
--- a/core/src/test/java/com/graphhopper/routing/util/CarFlagEncoderTest.java
+++ b/core/src/test/java/com/graphhopper/routing/util/CarFlagEncoderTest.java
@@ -191,6 +191,14 @@ public class CarFlagEncoderTest {
         assertTrue(accessEnc.getBool(false, flags));
         assertFalse(accessEnc.getBool(true, flags));
         way.clearTags();
+
+        // This is no one way
+        way.setTag("highway", "tertiary");
+        way.setTag("vehicle:backward", "designated");
+        flags = encoder.handleWayTags(em.createEdgeFlags(), way, encoder.getAccess(way));
+        assertTrue(accessEnc.getBool(false, flags));
+        assertTrue(accessEnc.getBool(true, flags));
+        way.clearTags();
     }
 
     @Test
diff --git a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomModelParserTest.java b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomModelParserTest.java
index 8347e459f..7afab6a40 100644
--- a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomModelParserTest.java
+++ b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomModelParserTest.java
@@ -224,8 +224,8 @@ class CustomModelParserTest {
                 new HashMap<>()));
         customModel.setAreas(areas);
 
-        customModel.addToSpeed(If("in_area_area_1", LIMIT, 100));
-        customModel.addToSpeed(If("!in_area_area_2", LIMIT, 25));
+        customModel.addToSpeed(If("in_area_1", LIMIT, 100));
+        customModel.addToSpeed(If("!in_area_2", LIMIT, 25));
         customModel.addToSpeed(Else(LIMIT, 15));
 
         // No exception is thrown during createWeightingParameters
@@ -236,9 +236,9 @@ class CustomModelParserTest {
         CustomModel customModel2 = new CustomModel();
         customModel2.setAreas(areas);
 
-        customModel2.addToSpeed(If("in_area_area_1", LIMIT, 100));
-        customModel2.addToSpeed(If("in_area_area_2", LIMIT, 25));
-        customModel2.addToSpeed(If("in_area_area_3", LIMIT, 150));
+        customModel2.addToSpeed(If("in_area_1", LIMIT, 100));
+        customModel2.addToSpeed(If("in_area_2", LIMIT, 25));
+        customModel2.addToSpeed(If("in_area_3", LIMIT, 150));
         customModel2.addToSpeed(Else(LIMIT, 15));
 
         assertThrows(IllegalArgumentException.class, () ->
diff --git a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
index 5f4a9ea2f..3cd792509 100644
--- a/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
+++ b/core/src/test/java/com/graphhopper/routing/weighting/custom/CustomWeightingTest.java
@@ -219,7 +219,7 @@ class CustomWeightingTest {
 
         CustomModel vehicleModel = new CustomModel();
         vehicleModel.addToPriority(If("road_class == PRIMARY", MULTIPLY, 1.0));
-        vehicleModel.addToPriority(If("in_area_custom1", MULTIPLY, 0.5));
+        vehicleModel.addToPriority(If("in_custom1", MULTIPLY, 0.5));
 
         ObjectMapper om = new ObjectMapper().registerModule(new JtsModule());
         JsonFeature json = om.readValue("{ \"geometry\":{ \"type\": \"Polygon\", \"coordinates\": " +
diff --git a/core/src/test/java/com/graphhopper/routing/weighting/custom/ExpressionVisitorTest.java b/core/src/test/java/com/graphhopper/routing/weighting/custom/ExpressionVisitorTest.java
index 5fdeda7c5..0376f6269 100644
--- a/core/src/test/java/com/graphhopper/routing/weighting/custom/ExpressionVisitorTest.java
+++ b/core/src/test/java/com/graphhopper/routing/weighting/custom/ExpressionVisitorTest.java
@@ -72,8 +72,8 @@ public class ExpressionVisitorTest {
         assertEquals("toll == Toll.NO || road_class == RoadClass.NO", parseExpression("toll == NO || road_class == NO", validVariable, lookup).converted.toString());
 
         // convert in_area variable to function call:
-        assertEquals(CustomWeightingHelper.class.getSimpleName() + ".in(this.in_area_custom_1, edge)",
-                parseExpression("in_area_custom_1", validVariable, lookup).converted.toString());
+        assertEquals(CustomWeightingHelper.class.getSimpleName() + ".in(this.in_custom_1, edge)",
+                parseExpression("in_custom_1", validVariable, lookup).converted.toString());
 
         // no need to inject:
         assertNull(parseExpression("toll == Toll.NO", validVariable, lookup).converted);
diff --git a/web-bundle/src/main/js/custom-model-editor/src/complete.test.js b/web-bundle/src/main/js/custom-model-editor/src/complete.test.js
index 892ae7b42..9b6421533 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/complete.test.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/complete.test.js
@@ -1,8 +1,9 @@
-import { complete } from './complete';
+import {complete} from './complete';
 
 const categories = {
-        'a': {type: 'enum', values: ['a1a', 'a1b', 'a2a', 'a2b']},
-        'b': {type: 'enum', values: ['b1', 'b2']}
+    'a': {type: 'enum', values: ['a1a', 'a1b', 'a2a', 'a2b']},
+    'b': {type: 'enum', values: ['b1', 'b2']},
+    'c': {type: 'numeric'}
 };
 const areas = ['pqr', 'xyz'];
 
@@ -11,12 +12,12 @@ describe("complete", () => {
         test_complete('a == ', 5, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 5]);
         test_complete('b == ', 5, ['b1', 'b2'], [5, 5]);
         test_complete('b ==       ', 5, ['b1', 'b2'], [5, 5]);
-        test_complete('', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
-        test_complete('  ', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
-        test_complete('\t\n', 12, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [12, 12]);
-        test_complete('    ', 0, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 0]);
-        test_complete('    ', 1, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [1, 1]);
-        test_complete('    ', 2, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [2, 2]);
+        test_complete('', 12, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [12, 12]);
+        test_complete('  ', 12, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [12, 12]);
+        test_complete('\t\n', 12, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [12, 12]);
+        test_complete('    ', 0, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [0, 0]);
+        test_complete('    ', 1, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [1, 1]);
+        test_complete('    ', 2, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [2, 2]);
         test_complete('b == ', 4, ['b1', 'b2'], [4, 4]);
         test_complete('b ==', 4, ['b1', 'b2'], [4, 4]);
         test_complete('b ==', 9, ['b1', 'b2'], [9, 9]);
@@ -54,7 +55,7 @@ describe("complete", () => {
     });
 
     test("complete at token within expression", () => {
-        test_complete('a == a1a && b != b1', 0, ['a', 'b', 'in_area_pqr', 'in_area_xyz', 'true', 'false'], [0, 1]);
+        test_complete('a == a1a && b != b1', 0, ['a', 'b', 'c', 'in_pqr', 'in_xyz', 'true', 'false'], [0, 1]);
         test_complete('a == a1a && b != b2', 2, ['==', '!='], [2, 4]);
         test_complete('a == a1b && b == b1', 5, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 8]);
         test_complete('a == a2a && b == b2', 6, ['a1a', 'a1b', 'a2a', 'a2b'], [5, 8]);
@@ -72,8 +73,17 @@ describe("complete", () => {
     });
 
     test("complete areas", () => {
-       test_complete('in_ && a == a1a', 2, ['in_area_pqr', 'in_area_xyz'], [0, 3]);
-       test_complete('in_area_x && a == a1a', 9, ['in_area_xyz'], [0, 9]);
+        test_complete('in_ && a == a1a', 2, ['in_pqr', 'in_xyz'], [0, 3]);
+        test_complete('in_x && a == a1a', 4, ['in_xyz'], [0, 4]);
+    });
+
+    test("complete update", () => {
+        test_complete(`c < 9`, 4, [`__hint__type a number`], [4, 5]);
+        // 'continuing' a number does not work because the __hint__ suggestion is filtered when it is compared with
+        // the existing digits. should we change this?
+        test_complete(`c < 9 || b == b1`, 5, [], null);
+        test_complete(`c < 9 `, 5, [], null);
+        test_complete(`c < 9`, 5, [], null);
     });
 });
 
diff --git a/web-bundle/src/main/js/custom-model-editor/src/parse.test.js b/web-bundle/src/main/js/custom-model-editor/src/parse.test.js
index 7bdabebc9..15afa60c3 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/parse.test.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/parse.test.js
@@ -9,7 +9,7 @@ const categories = {
     "bool2": {type: 'boolean'}
 }
 const areas = ["area1", "area2", "area3"];
-const allowedLefts = Object.keys(categories).concat(areas.map(a => 'in_area_' + a)).concat(['true', 'false']);
+const allowedLefts = Object.keys(categories).concat(areas.map(a => 'in_' + a)).concat(['true', 'false']);
 
 describe("parse", () => {
 
@@ -32,10 +32,10 @@ describe("parse", () => {
         test_parseTokens_valid(['(', 'a', '==', 'a1', ')']);
         test_parseTokens_valid(['(', 'a', '!=', 'a1', ')']);
         test_parseTokens_valid(['(', '(', 'a', '!=', 'a1', ')', ')']);
-        test_parseTokens_valid(['in_area_area1']);
-        test_parseTokens_valid(['(', 'in_area_area1', ')']);
-        test_parseTokens_valid(['in_area_area2', '==', 'true']);
-        test_parseTokens_valid(['(', 'in_area_area1', '!=', 'false', ')']);
+        test_parseTokens_valid(['in_area1']);
+        test_parseTokens_valid(['(', 'in_area1', ')']);
+        test_parseTokens_valid(['in_area2', '==', 'true']);
+        test_parseTokens_valid(['(', 'in_area1', '!=', 'false', ')']);
         test_parseTokens_valid(['true']);
         test_parseTokens_valid(['(', 'false', ')']);
         test_parseTokens_valid(['bool1']);
@@ -69,11 +69,10 @@ describe("parse", () => {
     });
 
     test("parse single comparison, invalid, area", () => {
-        test_parseTokens(['in_area_'], `unknown area: ''`, [0, 1], ['in_area_area1', 'in_area_area2', 'in_area_area3']);
-        test_parseTokens(['in_area_area404'], `unknown area: 'area404'`, [0, 1], ['in_area_area1', 'in_area_area2', 'in_area_area3']);
-        test_parseTokens(['in_area1'], `area names must be prefixed with 'in_area_'`, [0, 1], ['in_area_area1', 'in_area_area2', 'in_area_area3']);
-        test_parseTokens(['area2'], `area names must be prefixed with 'in_area_'`, [0, 1], ['in_area_area1', 'in_area_area2', 'in_area_area3']);
-        test_parseTokens(['in_area_area1', '<=', 'true'], `unexpected token '<='`, [1, 2], ['||', '&&']);
+        test_parseTokens(['in_'], `unknown area: ''`, [0, 1], ['in_area1', 'in_area2', 'in_area3']);
+        test_parseTokens(['in_area404'], `unknown area: 'area404'`, [0, 1], ['in_area1', 'in_area2', 'in_area3']);
+        test_parseTokens(['area2'], `area names must be prefixed with 'in_'`, [0, 1], ['in_area1', 'in_area2', 'in_area3']);
+        test_parseTokens(['in_area1', '<=', 'true'], `unexpected token '<='`, [1, 2], ['||', '&&']);
     });
 
     test("parse single comparison, invalid, numeric and boolean", () => {
@@ -88,9 +87,9 @@ describe("parse", () => {
         test_parseTokens_valid(['a', '==', 'a1', '&&', 'b', '==', 'b1']);
         test_parseTokens_valid(['a', '==', 'a1', '||', 'b', '==', 'b1', '&&', 'a', '!=', 'a2']);
         test_parseTokens_valid(['a', '==', 'a1', '||', '(', 'b', '==', 'b1', ')', '&&', 'a', '!=', 'a2']);
-        test_parseTokens_valid(['in_area_area3', '&&', 'a', '==', 'a1']);
-        test_parseTokens_valid(['b', '!=', 'b1', '||', 'in_area_area3']);
-        test_parseTokens_valid(['b', '!=', 'b1', '||', 'in_area_area2', '!=', 'true']);
+        test_parseTokens_valid(['in_area3', '&&', 'a', '==', 'a1']);
+        test_parseTokens_valid(['b', '!=', 'b1', '||', 'in_area3']);
+        test_parseTokens_valid(['b', '!=', 'b1', '||', 'in_area2', '!=', 'true']);
         test_parseTokens_valid(['bool1', '==', 'false', '&&', 'bool2', '||', 'bool1']);
     });
 
@@ -130,11 +129,11 @@ describe("parse", () => {
         test_parse_valid('( a==a1 ) || b != b1 && ( a==a2 ||\n b!=b1 && (a==a1 || b == b2)) && a == a1');
         test_parse_valid('num1 > 0.3 && bool1 != true || a == a2 && num2 > 0.5 || bool1 == false');
         test_parse_valid('num1>0.3 && num2<12 || num1>=0.5 && num2<=1.3');
-        test_parse_valid('num1>0.3 && in_area_area2 || num2<=1.3');
+        test_parse_valid('num1>0.3 && in_area2 || num2<=1.3');
         test_parse_valid('num1>0.3 && bool1');
-        test_parse_valid('a != a1 && (bool1 || (bool2 && in_area_area2))');
-        test_parse_valid('a != a1 && (bool1 != false || (in_area_area2 && bool2))');
-        test_parse_valid('a != a1 && (bool1 != false || ((in_area_area1 == false) && bool2))');
+        test_parse_valid('a != a1 && (bool1 || (bool2 && in_area2))');
+        test_parse_valid('a != a1 && (bool1 != false || (in_area2 && bool2))');
+        test_parse_valid('a != a1 && (bool1 != false || ((in_area1 == false) && bool2))');
     });
 
     test("parse, invalid", () => {
@@ -153,11 +152,11 @@ describe("parse", () => {
         test_parse(`a == a1 || bool2 == 'false'`, `invalid bool2: ''false''`, [20, 27], ['true', 'false']);
         test_parse(`b != b1 || num2 > 0.7 && bool1 != 'true'`, `invalid bool1: ''true''`, [34, 40], ['true', 'false']);
         test_parse('a == a2 && b <= b1', `invalid operator '<='`, [13, 15], ['==', '!=']);
-        test_parse('a == a1 || area1', `area names must be prefixed with 'in_area_'`, [11, 16], ['in_area_area1', 'in_area_area2', 'in_area_area3']);
+        test_parse('a == a1 || area1', `area names must be prefixed with 'in_'`, [11, 16], ['in_area1', 'in_area2', 'in_area3']);
         test_parse('a == a1 || area_1', `unexpected token 'area_1'`, [11, 17], allowedLefts);
         test_parse(' == bool1', `unexpected token '=='`, [1, 3], allowedLefts);
         test_parse('bool1 != a2', `invalid bool1: 'a2'`, [9, 11], ['true', 'false']);
-        test_parse('in_area_area1 == tru || a == a1', `invalid in_area_area1: 'tru'`, [17, 20], ['true', 'false']);
+        test_parse('in_area1 == tru || a == a1', `invalid in_area1: 'tru'`, [12, 15], ['true', 'false']);
     });
 
     function test_parse_valid(expression) {
diff --git a/web-bundle/src/main/js/custom-model-editor/src/validate.test.js b/web-bundle/src/main/js/custom-model-editor/src/validate.test.js
index f3cc5910c..379b39d71 100644
--- a/web-bundle/src/main/js/custom-model-editor/src/validate.test.js
+++ b/web-bundle/src/main/js/custom-model-editor/src/validate.test.js
@@ -7,40 +7,40 @@ describe("validate", () => {
 
     test('root must be an object', () => {
         test_validate(`[]`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: list, range: [0, 2]`
+            `root: must be an object. given type: list, range: [0, 2]`
         ]);
         test_validate(`-\n-\n`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: list, range: [0, 4]`
+            `root: must be an object. given type: list, range: [0, 4]`
         ]);
         test_validate(`abc`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: string, range: [0, 3]`
+            `root: must be an object. given type: string, range: [0, 3]`
         ]);
         test_validate(`'abc'`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: string, range: [0, 5]`
+            `root: must be an object. given type: string, range: [0, 5]`
         ]);
         test_validate(`"abc"`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: string, range: [0, 5]`
+            `root: must be an object. given type: string, range: [0, 5]`
         ]);
         test_validate(`301`, [
-            `root: must be an object. possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given type: number, range: [0, 3]`
+            `root: must be an object. given type: number, range: [0, 3]`
         ]);
     });
 
     test('root keys are not empty', () => {
         test_validate(`:`, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: null, range: [0, 1]`
+            `root: keys must not be null, range: [0, 1]`
         ]);
         test_validate(`''  :`, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: '', range: [0, 4]`
+            `root: keys must be non-empty and must not only consist of whitespace. given: '', range: [0, 4]`
         ]);
         test_validate(`"    " :`, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: '    ', range: [0, 7]`
+            `root: keys must be non-empty and must not only consist of whitespace. given: '    ', range: [0, 7]`
         ]);
         test_validate(`"\n" :`, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: ' ', range: [0, 4]`
+            `root: keys must be non-empty and must not only consist of whitespace. given: ' ', range: [0, 4]`
         ]);
         test_validate(`"" \t: \n `, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: '', range: [0, 4]`
+            `root: keys must be non-empty and must not only consist of whitespace. given: '', range: [0, 4]`
         ]);
     });
 
@@ -51,10 +51,13 @@ describe("validate", () => {
         test_validate(`spee: [dprio]`, [
             `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: 'spee', range: [0, 4]`
         ]);
+        test_validate(`[]: abc`, [
+            `root: keys must be strings. given type: list, range: [0, 2]`
+        ]);
         // todo: this error message is not very helpful unfortunately, maybe it would be better to check
         // one section after the other instead of first checking all root keys?
         test_validate(`speed: \n    multiply_by: 0.3\n  - if: condition`, [
-            `root: possible keys: ['speed', 'priority', 'distance_influence', 'areas']. given: null, range: [0, 46]`
+            `root: keys must not be null, range: [0, 46]`
         ]);
     });
 
@@ -73,10 +76,10 @@ describe("validate", () => {
             `priority: must not be null, range: [29, 37]`
         ]);
         test_validate(`speed: \n  -`, [
-            `speed[0]: every statement must be an object with a clause ['if', 'else_if', 'else'] and an operator ['multiply_by', 'limit_to']. given type: null, range: [10, 11]`
+            `speed[0]: must not be null, range: [10, 11]`
         ]);
         test_validate(`speed:\n  - {if: condition, multiply_by: 0.9}\n  -`, [
-            `speed[1]: every statement must be an object with a clause ['if', 'else_if', 'else'] and an operator ['multiply_by', 'limit_to']. given type: null, range: [47, 48]`
+            `speed[1]: must not be null, range: [47, 48]`
         ]);
     });
 
@@ -110,7 +113,7 @@ describe("validate", () => {
 
     test('speed/priority statements keys are valid', () => {
         test_validate(`speed: ['abc']`, [
-            `speed[0]: every statement must be an object with a clause ['if', 'else_if', 'else'] and an operator ['multiply_by', 'limit_to']. given type: string, range: [8, 13]`
+            `speed[0]: must be an object. given type: string, range: [8, 13]`
         ]);
         test_validate(`speed: [{abc: def}]`, [
             `speed[0]: possible keys: ['if', 'else_if', 'else', 'multiply_by', 'limit_to']. given: 'abc', range: [9, 12]`
@@ -124,7 +127,7 @@ describe("validate", () => {
         ]);
         test_validate(`priority: [{if: condition, limit_to: 100}, {if: condition, else: null, multiply_by: 0.3}]`, [
             `priority[1]: too many keys. maximum: 2. given: else,if,multiply_by, range: [43, 88]`
-        ])
+        ]);
         test_validate(`priority: [{limit_to: 100, multiply_by: 0.3}]`, [
             `priority[0]: every statement must have a clause ['if', 'else_if', 'else']. given: limit_to,multiply_by, range: [11, 44]`
         ]);
@@ -135,35 +138,44 @@ describe("validate", () => {
             `priority[1]: every statement must have an operator ['multiply_by', 'limit_to']. given: if, range: [44, 60]`
         ]);
         test_validate(`speed: [ if: condition, limit_to: 100 ]`, [
-            `speed[0]: every statement must be an object with a clause ['if', 'else_if', 'else'] and an operator ['multiply_by', 'limit_to']. given type: pair, range: [9, 22]`,
-            `speed[1]: every statement must be an object with a clause ['if', 'else_if', 'else'] and an operator ['multiply_by', 'limit_to']. given type: pair, range: [24, 38]`
+            `speed: must not contain pairs, range: [7, 39]`
         ]);
     });
 
     test('speed/priority statements conditions must be strings or booleans (or null for else)', () => {
         test_validate(`speed: [{if: condition, limit_to: 30}, {else: condition, multiply_by: 0.4}]`, [
-            `speed[1]: the value of 'else' must be null. given: 'condition', range: [46, 55]`
+            `speed[1][else]: must be null. given: 'condition', range: [46, 55]`
         ]);
         test_validate(`priority: [{if : [], multiply_by: 0.4}]`, [
-            `priority[0]: the value of 'if' must be a string or boolean. given type: list, range: [17, 19]`
+            `priority[0][if]: must be a string or boolean. given type: list, range: [17, 19]`
         ])
         test_validate(`priority: [{if : {}, multiply_by: 0.4}]`, [
-            `priority[0]: the value of 'if' must be a string or boolean. given type: object, range: [17, 19]`
+            `priority[0][if]: must be a string or boolean. given type: object, range: [17, 19]`
         ])
         test_validate(`priority: [{if : 35, multiply_by: 0.4}]`, [
-            `priority[0]: the value of 'if' must be a string or boolean. given type: number, range: [17, 19]`
+            `priority[0][if]: must be a string or boolean. given type: number, range: [17, 19]`
         ])
         test_validate(`speed: [{if: condition, multiply_by: 0.2}, {else_if: 3.4, limit_to: 12}]`, [
-            `speed[1]: the value of 'else_if' must be a string or boolean. given type: number, range: [53, 56]`
+            `speed[1][else_if]: must be a string or boolean. given type: number, range: [53, 56]`
         ]);
         test_validate(`speed:\n  - if:     \n    limit_to: 100`, [
-            `speed[0]: the value of 'if' must be a string or boolean. given type: null, range: [11, 13]`
+            `speed[0][if]: must be a string or boolean. given type: null, range: [11, 13]`
         ]);
         test_validate(`speed: [{if: condition, multiply_by: 0.2}, {else_if: , limit_to: 12}]`, [
-            `speed[1]: the value of 'else_if' must be a string or boolean. given type: null, range: [44, 51]`
+            `speed[1][else_if]: must be a string or boolean. given type: null, range: [44, 51]`
         ]);
         test_validate(`speed: [{limit_to: 100, if:`, [
-            `speed[0]: the value of 'if' must be a string or boolean. given type: null, range: [24, 26]`
+            `speed[0][if]: must be a string or boolean. given type: null, range: [24, 26]`
+        ]);
+        test_validate(`speed: [ if:`, [
+            `speed[0]: must be an object. given type: string, range: [9, 11]`,
+            `speed[1]: must not be null, range: [11, 12]`
+        ]);
+        test_validate(`speed: [ if: `, [
+            `speed: must not contain pairs, range: [7, 13]`,
+        ]);
+        test_validate(`speed: [ if: if: `, [
+            `speed: must not contain pairs, range: [7, 17]`
         ]);
         test_validate(`speed: [{if: true, multiply_by: 0.15}]`, []);
     });
@@ -183,16 +195,16 @@ describe("validate", () => {
 
     test('speed/priority operator values must be numbers', () => {
         test_validate(`speed: [{if: condition, multiply_by: []}]`, [
-            `speed[0]: the value of 'multiply_by' must be a number. given type: list, range: [37, 39]`
+            `speed[0][multiply_by]: must be a number. given type: list, range: [37, 39]`
         ]);
         test_validate(`priority: [{if: condition, multiply_by: {}}]`, [
-            `priority[0]: the value of 'multiply_by' must be a number. given type: object, range: [40, 42]`
+            `priority[0][multiply_by]: must be a number. given type: object, range: [40, 42]`
         ]);
         test_validate(`speed: [{if: condition, limit_to: abc}]`, [
-            `speed[0]: the value of 'limit_to' must be a number. given type: string, range: [34, 37]`
+            `speed[0][limit_to]: must be a number. given type: string, range: [34, 37]`
         ]);
         test_validate(`speed: [{if: condition, limit_to: }]`, [
-            `speed[0]: the value of 'limit_to' must be a number. given type: null, range: [24, 32]`
+            `speed[0][limit_to]: must be a number. given type: null, range: [24, 32]`
         ])
     });
 
@@ -215,25 +227,132 @@ describe("validate", () => {
     test('area names must be strings', () => {
         test_validate(`areas: { : {}}`, [`areas: keys must not be null, range: [7, 14]`]);
         test_validate(`areas: { [] : {}}`, [`areas: keys must be strings. given type: list, range: [9, 12]`]);
-        test_validate(`areas: { '' : {}}`, [`areas: keys must not be empty. given: '', range: [9, 12]`]);
-        test_validate(`areas: {    "   " : {}}`, [`areas: invalid area name: '   ', only a-z, digits and _ are allowed, range: [12, 18]`]);
-        test_validate(`areas: {    " abc  " : {}}`, [`areas: invalid area name: ' abc  ', only a-z, digits and _ are allowed, range: [12, 21]`]);
-        test_validate(`areas: {    "a bc" : {}}`, [`areas: invalid area name: 'a bc', only a-z, digits and _ are allowed, range: [12, 19]`]);
-        test_validate(`areas: {    "_abc" : {}}`, [`areas: invalid area name: '_abc', only a-z, digits and _ are allowed, range: [12, 19]`]);
-        test_validate(`areas: {    "9abc" : {}}`, [`areas: invalid area name: '9abc', only a-z, digits and _ are allowed, range: [12, 19]`]);
-        test_validate(`areas: {    "a__9bc" : {}}`, []);
+        test_validate(`areas: { '' : {}}`, [`areas: keys must be non-empty and must not only consist of whitespace. given: '', range: [9, 12]`]);
+        test_validate(`areas: {    "   " : {}}`, [`areas: keys must be non-empty and must not only consist of whitespace. given: '   ', range: [12, 18]`]);
+        test_validate(`areas: {    " abc  " : {}}`, [`areas: names may only contain a-z, digits and _. given: ' abc  ', range: [12, 21]`]);
+        test_validate(`areas: {    "a bc" : {}}`, [`areas: names may only contain a-z, digits and _. given: 'a bc', range: [12, 19]`]);
+        test_validate(`areas: {    "_abc" : {}}`, [`areas: names may only contain a-z, digits and _. given: '_abc', range: [12, 19]`]);
+        test_validate(`areas: {    "9abc" : {}}`, [`areas: names may only contain a-z, digits and _. given: '9abc', range: [12, 19]`]);
     });
 
     test(`area names must be unique`, () => {
-        test_validate(`areas:\n  area1: {}\n  area2: {}\n  area3: {}`, []);
         test_validate(`areas:\n  area1: {}\n  area2: {}\n  area1: {}`, [
             `areas: keys must be unique. duplicate: 'area1', range: [33, 38]`
         ]);
     });
 
     test(`area names get returned correctly`, () => {
-        expectAreas(`areas:\n  area1: {}\n  area2: {}`, ['area1', 'area2']);
-        expectAreas(`areas: { x2: {}, p_qr: {}, rst6: {}}`, ['x2', 'p_qr', 'rst6']);
+        expectAreaNames(`areas:\n  area1: {}\n  area2: {}`, ['area1', 'area2']);
+        expectAreaNames(`areas: { x2: {}, p_qr: {}, rst6: {}}`, ['x2', 'p_qr', 'rst6']);
+    });
+
+    test('areas must be objects', () => {
+        test_validate(`areas: {area1: `, [
+            `areas[area1]: must not be null, range: [8, 13]`
+        ]);
+
+        test_validate(`areas:\n  area1: []\n  area2: abc\n  area3: 100`, [
+            `areas[area1]: must be an object. given type: list, range: [16, 18]`,
+            `areas[area2]: must be an object. given type: string, range: [28, 31]`,
+            `areas[area3]: must be an object. given type: number, range: [41, 44]`
+        ]);
+    });
+
+    test('area keys', () => {
+        test_validate(`areas:\n  area1:\n    x: y\n    p: q`, [
+            `areas[area1]: possible keys: ['type', 'geometry', 'id', 'properties']. given: 'x', range: [20, 21]`,
+            `areas[area1]: possible keys: ['type', 'geometry', 'id', 'properties']. given: 'p', range: [29, 30]`
+        ]);
+
+        test_validate(`areas:\n  area1:\n    type: y\n    id: q`, [
+            `areas[area1]: missing 'geometry'. given: ['type', 'id'], range: [20, 37]`
+        ]);
+
+        test_validate(`areas: { area1: { geometry: {}`, [
+            `areas[area1]: missing 'type'. given: ['geometry'], range: [16, 30]`
+        ]);
+    });
+
+    test('area values', () => {
+        test_validate(`areas: {area1: { type: 'Future', geometry: }}`, [
+            `areas[area1][type]: must be 'Feature'. given: 'Future', range: [23, 31]`,
+            `areas[area1][geometry]: must not be null, range: [33, 41]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: []}}`, [
+            `areas[area1][geometry]: must be an object. given type: list, range: [44, 46]`
+        ]);
+        const validGeometry = `{type: Polygon, coordinates: [[[1,1], [2,2], [3,3], [1,1]]]}`;
+        test_validate(`areas: {area1: { type: 'Feature', geometry: ${validGeometry}, properties: []}}`, [
+            `areas[area1][properties]: must be an object. given type: list, range: [118, 120]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: ${validGeometry}, id: 123}}`, [
+            `areas[area1][id]: must be a string. given type: number, range: [110, 113]`
+        ]);
+    });
+
+    test('area geometry', () => {
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: bla} }}`, [
+            `areas[area1][geometry]: missing 'coordinates'. given: ['type'], range: [44, 56]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {teip: bla} }}`, [
+            `areas[area1][geometry]: possible keys: ['type', 'coordinates']. given: 'teip', range: [45, 49]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {coordinates: [[[1,1], [2,2], [3,3], [1,1]]]} }}`, [
+            `areas[area1][geometry]: missing 'type'. given: ['coordinates'], range: [44, 90]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: , coordinates: [[[1,1], [2,2], [3,3], [1,1]]]} }}`, [
+            `areas[area1][geometry][type]: must not be null, range: [45, 49]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: bla, coordinates: [[[1,1], [2,2], [3,3], [1,1]]]} }}`, [
+            `areas[area1][geometry][type]: must be 'Polygon'. given: 'bla', range: [51, 54]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: 'Polygon', coordinates: } }}`, [
+            `areas[area1][geometry][coordinates]: must not be null, range: [62, 73]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: "Polygon", coordinates: abc} }}`, [
+            `areas[area1][geometry][coordinates]: must be a list. given type: string, range: [75, 78]`
+        ]);
+    });
+
+    test('area geometry coordinates list', () => {
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: []} }}`, [
+            `areas[area1][geometry][coordinates]: minimum length: 1, given: 0, range: [73, 75]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [abc]} }}`, [
+            `areas[area1][geometry][coordinates][0]: must be a list. given type: string, range: [74, 77]`
+        ]);
+        // todo: check range and whitespace again
+        test_validate(`areas:\n area1:\n  type: Feature\n  geometry:\n   type: Polygon\n   coordinates: \n    - \n   `, [
+            `areas[area1][geometry][coordinates][0]: must not be null, range: [81, 83]`,
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [{}, abc]} }}`, [
+            `areas[area1][geometry][coordinates][0]: must be a list. given type: object, range: [74, 76]`,
+            `areas[area1][geometry][coordinates][1]: must be a list. given type: string, range: [78, 81]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[]]} }}`, [
+            `areas[area1][geometry][coordinates][0]: minimum length: 4, given: 0, range: [74, 76]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[], [], [], []]} }}`, [
+            `areas[area1][geometry][coordinates][0]: minimum length: 4, given: 0, range: [74, 76]`,
+            `areas[area1][geometry][coordinates][1]: minimum length: 4, given: 0, range: [78, 80]`,
+            `areas[area1][geometry][coordinates][2]: minimum length: 4, given: 0, range: [82, 84]`,
+            `areas[area1][geometry][coordinates][3]: minimum length: 4, given: 0, range: [86, 88]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[[0, 0], [1, p], [2, 2], [x, 0]]]} }}`, [
+            `areas[area1][geometry][coordinates][0][1][1]: must be a number, range: [87, 88]`,
+            `areas[area1][geometry][coordinates][0][3][0]: must be a number, range: [100, 101]`,
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[[0, 0], [1, 1], [2, 2], [0, 0, p]]]} }}`, [
+            `areas[area1][geometry][coordinates][0][3]: maximum length: 2, given: 3, range: [99, 108]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[[1, 2], [3, 4], [5, 6], [1.1, 2.2]]]} }}`, [
+            `areas[area1][geometry][coordinates][0]: the last point must be equal to the first, range: [99, 109]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[[100, 200], [300, 40], [5, 6], [1.1, 2.2]]]} }}`, [
+            `areas[area1][geometry][coordinates][0][0][1]: latitude must be in [-90, +90], range: [81, 84]`,
+            `areas[area1][geometry][coordinates][0][1][0]: longitude must be in [-180, +180], range: [88, 91]`
+        ]);
+        test_validate(`areas: {area1: { type: 'Feature', geometry: {type: Polygon, coordinates: [[[1, 2], [3, 4], [5, 6], [1, 2]]]} }}`, []);
     });
 
     test(`includes yaml parser errors`, () => {
@@ -247,13 +366,12 @@ describe("validate", () => {
     });
 });
 
-function expectAreas(doc, areas) {
+function expectAreaNames(doc, areas) {
     try {
         const res = validate(doc);
-        expect(res.errors).toStrictEqual([]);
         expect(res.areas).toStrictEqual(areas);
     } catch (e) {
-        Error.captureStackTrace(e, expectAreas);
+        Error.captureStackTrace(e, expectAreaNames);
         throw e;
     }
 }
diff --git a/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.test.js b/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.test.js
new file mode 100644
index 000000000..d46297c44
--- /dev/null
+++ b/web-bundle/src/main/js/custom-model-editor/src/yaml_complete.test.js
@@ -0,0 +1,141 @@
+import {completeYaml, getYamlPath} from "./yaml_complete";
+
+const rootElements = [`speed`, `priority`, `distance_influence`, `areas`];
+const statementElements = [`if`, `else_if`, `else`, `limit_to`, `multiply_by`];
+
+describe('complete_yaml', () => {
+    test('root elements', () => {
+        test_complete(``, 0, rootElements, [0, 1]);
+        test_complete(` `, 0, rootElements, [0, 2]);
+        test_complete(`x`, 0, rootElements, [0, 1]);
+        test_complete(`speed:`, 6, rootElements, [0, 7]);
+        test_complete(`speed: `, 6, [], []);
+        test_complete(`speed: []`, 5, rootElements, [0, 5]);
+        test_complete(`speed: []`, 6, [], []);
+        // only if our cursor is separated by at least a space the current token is not the key but the (speed) value
+        test_complete(`speed:  `, 7, [], []);
+        test_complete(`speed`, 5, rootElements, [0, 6]);
+        test_complete(`speed `, 5, rootElements, [0, 7]);
+        test_complete(`priority: []`, 2, rootElements, [0, 8]);
+        test_complete(`speed: []\n\n\ndistance_influence: 10`, 10, rootElements.filter(s => s !== 'speed' && s !== 'distance_influence'), [10, 11]);
+        test_complete(`speed: []\nx\ndistance_influence: 10`, 10, rootElements.filter(s => s !== 'speed' && s !== 'distance_influence'), [10, 11]);
+        test_complete(`{\nspeed: [], x`, 13, rootElements.filter(s => s !== 'speed'), [13, 14]);
+        // range can exceed the current line, but this is handled later in custom model editor, note how the 'key'
+        // can span multiple lines in yaml
+        test_complete(` \nareas: []`, 0, rootElements, [0, 8]);
+        test_complete(` \nareas: []`, 1, rootElements, [1, 8]);
+        test_complete(` \nareas: []`, 2, rootElements, [2, 7]);
+        test_complete(`\nareas: []`, 0, rootElements, [0, 7]);
+    });
+
+    test('statements', () => {
+        test_complete(`speed: [ { x`, 11, statementElements, [11, 12]);
+        test_complete(`priority: [ {}, { x`, 18, statementElements, [18, 19]);
+        test_complete(`speed: [ { a: b, x`, 17, statementElements, [17, 18]);
+        // this one is a bit unfortunate, it is correct yaml for a list even without the newline and ` - `,
+        // but really it is not very helpful
+        // todo: fix this by differentiating between seq and flow_seq?
+        test_complete(`speed: [ x`, 9, statementElements, [9, 10]);
+        // ... like this we need it
+        test_complete(`speed:\n  - x`, 11, statementElements, [11, 12]);
+        test_complete(`priority:\n  - if: abc\n    limit_to: 30\n  - x`, 43, statementElements, [43, 44]);
+        test_complete(`priority:\n  - if: abc\n    xyz123`, 27, [`limit_to`, `multiply_by`], [26, 32]);
+        test_complete(`priority:\n  - limit_to: 100\n    xyz123`, 33, [`if`, `else_if`, `else`], [32, 38]);
+        test_complete(`speed:\n  - if: abc\n    x`, 23, [`limit_to`, `multiply_by`], [23, 24]);
+        test_complete(`speed:\n  - if: abc\n     `, 23, [`limit_to`, `multiply_by`], [23, 25]);
+        test_complete(`speed:\n  - if: abc\n    `, 23, [`limit_to`, `multiply_by`], [23, 24]);
+        // conditions
+        test_complete(`speed: [ {if: x`, 14, [`__hint__type a condition`], [14, 15]);
+        test_complete(`speed:\n  - if: x`, 15, [`__hint__type a condition`], [15, 16]);
+        test_complete(`speed:\n  - if: abc\n    limit_to: 30\n  - else_if: my_condition`, 51, [`__hint__type a condition`], [49, 61]);
+        // operator values
+        test_complete(`speed: [{if: abc, limit_to:  x`, 29, [`__hint__type a number`], [29, 30]);
+        test_complete(`priority:\n  - if: abc\n    multiply_by: x`, 39, [`__hint__type a number`], [39, 40]);
+    });
+
+    test(`distance_influence`, () => {
+        test_complete(`distance_influence: x`, 20, [`__hint__type a number`], [20, 21]);
+        test_complete(`distance_influence: x\nspeed: []`, 20, [`__hint__type a number`], [20, 21]);
+        test_complete(`distance_influence: 123\nspeed: []`, 21, [`__hint__type a number`], [20, 23]);
+    });
+
+    test(`areas`, () => {
+        test_complete(`areas:\n  x`, 9, [`__hint__type an area name`], [9, 10]);
+        test_complete(`areas:\n  berlin: {}\n  x`, 22, [`__hint__type an area name`], [22, 23]);
+        test_complete(`areas:\n  berlin:\n    x`, 21, [`geometry`, `type`], [21, 22]);
+        test_complete(`areas:\n  berlin: {}\n  munich:\n    x`, 36, [`geometry`, `type`], [34, 36]);
+        test_complete(`areas:\n  berlin:\n    type: x`, 27, [`Feature`], [27, 28])
+        test_complete(`areas:\n  berlin:\n    type: Feature\n    x`, 39, [`geometry`], [39, 40]);
+    });
+
+    test(`areas geometry`, () => {
+        test_complete(`areas:\n berlin:\n  geometry:\n   x`, 32, [`type`, `coordinates`], [31, 33]);
+        test_complete(`areas:\n berlin:\n  geometry:\n   type: x`, 37, [`Polygon`], [37, 38]);
+        test_complete(`areas:\n berlin:\n  geometry:\n   type: Polygon\n   x`, 49, [`coordinates`], [48, 50]);
+    });
+
+    test(`handle pair`, () => {
+        test_complete(`areas:\n hanau: {\n   geometry: coordinates\n   type:  \n}`, 52, [], []);
+    });
+});
+
+function test_complete(content, pos, suggestions, range) {
+    try {
+        const result = completeYaml(content, pos);
+        expect(result.suggestions).toStrictEqual(suggestions);
+        expect(result.range).toStrictEqual(range);
+    } catch (e) {
+        Error.captureStackTrace(e, test_complete);
+        throw e;
+    }
+}
+
+describe("get_yaml_path", () => {
+    test('root elements', () => {
+        test_path(`x`, 0, [`root`, `x`], [0, 1]);
+        test_path(`x`, 1, [`root`, `x`], [0, 1]);
+        test_path(`x `, 1, [`root`, `x`], [0, 2]);
+        test_path(`{  x `, 3, [`root`, `map`, `pair`, `x`], [3, 5]);
+        test_path(`speed: [ x`, 9, [`root`, `map`, `pair[speed]`, `list[0]`, `x`], [9, 10]);
+        test_path(`speed: []\nx`, 10, [`root`, `map`, `pair`, `x`], [10, 11]);
+        test_path(`speed: []\nx\ndistance_influence: 10`, 10, [`root`, `map`, `pair`, `x`], [10, 11]);
+        test_path(`{\nspeed: [], x`, 13, [`root`, `map`, `pair`, `x`], [13, 14]);
+    });
+
+    test('statements', () => {
+        test_path(`speed: [ { x`, 11, [`root`, `map`, `pair[speed]`, `list[0]`, `map`, `pair`, `x`], [11, 12]);
+        test_path(`priority: [ {}, { x`, 18, [`root`, `map`, `pair[priority]`, `list[1]`, `map`, `pair`, `x`], [18, 19]);
+        test_path(`speed: [ { a: b, x`, 17, [`root`, `map`, `pair[speed]`, `list[0]`, `map`, `pair`, `x`], [17, 18]);
+        test_path(`priority:\n  - if: abc\n    limit_to: 30\n  - x`, 43, [`root`, `map`, `pair[priority]`, `list[1]`, `x`], [43, 44]);
+        test_path(`priority:\n  - if: abc\n    xyz`, 27, [`root`, `map`, `pair[priority]`, `list[0]`, `map`, `pair`, `xyz`], [26, 29]);
+        // conditions
+        test_path(`speed: [ {if: x`, 14, [`root`, `map`, `pair[speed]`, `list[0]`, `map`, `pair[if]`, `x`], [14, 15]);
+        test_path(`speed:\n  - if: x`, 15, [`root`, `map`, `pair[speed]`, `list[0]`, `map`, `pair[if]`, `x`], [15, 16]);
+        test_path(`speed:\n  - if: abc\n    limit_to: 30\n  - else_if: my_condition`, 51, [`root`, `map`, `pair[speed]`, `list[1]`, `map`, `pair[else_if]`, `my_condition`], [49, 61]);
+        // operator values
+        test_path(`speed: [{if: abc, limit_to:  x`, 29, [`root`, `map`, `pair[speed]`, `list[0]`, `map`, `pair[limit_to]`, `x`], [29, 30]);
+        test_path(`priority:\n  - if: abc\n    multiply_by: x`, 39, [`root`, `map`, `pair[priority]`, `list[0]`, `map`, `pair[multiply_by]`, `x`], [39, 40]);
+    });
+
+    test(`distance_influence`, () => {
+        test_path(`distance_influence: x`, 20, [`root`, `map`, `pair[distance_influence]`, `x`], [20, 21]);
+    });
+
+    test(`areas`, () => {
+        test_path(`areas:\n  x`, 9, [`root`, `map`, `pair[areas]`, `x`], [9, 10]);
+        test_path(`areas:\n  berlin:\n    x`, 21, [`root`, `map`, `pair[areas]`, `map`, `pair[berlin]`, `x`], [21, 22]);
+        test_path(`areas:\n  berlin:\n    type: Feature\n    x`, 39, [`root`, `map`, `pair[areas]`, `map`, `pair[berlin]`, `map`, `pair`, `x`], [39, 40]);
+        test_path(`areas:\n  berlin:\n    type: Feature\n    geometry: []\n  x`, 54, [`root`, `map`, `pair[areas]`, `map`, `pair`, `x`], [54, 55]);
+    });
+});
+
+function test_path(content, pos, expectedSignature, expectedTokenRange) {
+    try {
+        const yamlPath = getYamlPath(content, pos);
+        expect(yamlPath.signature).toStrictEqual(expectedSignature);
+        expect(yamlPath.tokenRange).toStrictEqual(expectedTokenRange);
+    } catch (e) {
+        Error.captureStackTrace(e, test_path);
+        throw e;
+    }
+}
\ No newline at end of file
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/helpers/requireFile.js b/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/helpers/requireFile.js
deleted file mode 100644
index 804d609cb..000000000
--- a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/helpers/requireFile.js
+++ /dev/null
@@ -1,3 +0,0 @@
-global.requireFile = function (name) {
-    return require('../../../../../../../../main/resources/com/graphhopper/maps/js/' + name);
-};
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/config/TileLayersSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/config/TileLayersSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/config/TileLayersSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/config/TileLayersSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/GHInputSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/GHInputSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/GHInputSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/GHInputSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/GHRequestSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/GHRequestSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/GHRequestSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/GHRequestSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/ToolsSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/ToolsSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/graphhopper/ToolsSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/graphhopper/ToolsSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/spec/helpers/requireFile.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/helpers/requireFile.js
new file mode 100644
index 000000000..6c75fdd76
--- /dev/null
+++ b/web-bundle/src/test/resources/com/graphhopper/maps/spec/helpers/requireFile.js
@@ -0,0 +1,3 @@
+global.requireFile = function (name) {
+    return require('../../../../../../../main/resources/com/graphhopper/maps/js/' + name);
+};
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/jasmine.json b/web-bundle/src/test/resources/com/graphhopper/maps/spec/jasmine.json
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/jasmine.json
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/jasmine.json
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/routeManipulationSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/routeManipulationSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/routeManipulationSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/routeManipulationSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/tools/formatSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/tools/formatSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/tools/formatSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/tools/formatSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/tools/urlSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/tools/urlSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/tools/urlSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/tools/urlSpec.js
diff --git a/web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/translateSpec.js b/web-bundle/src/test/resources/com/graphhopper/maps/spec/translateSpec.js
similarity index 100%
rename from web-bundle/src/test/resources/com/graphhopper/maps/assets/spec/translateSpec.js
rename to web-bundle/src/test/resources/com/graphhopper/maps/spec/translateSpec.js
diff --git a/web/src/test/java/com/graphhopper/http/resources/CustomWeightingRouteResourceTest.java b/web/src/test/java/com/graphhopper/http/resources/CustomWeightingRouteResourceTest.java
index 4414c0b6e..673f22d34 100644
--- a/web/src/test/java/com/graphhopper/http/resources/CustomWeightingRouteResourceTest.java
+++ b/web/src/test/java/com/graphhopper/http/resources/CustomWeightingRouteResourceTest.java
@@ -102,7 +102,7 @@ public class CustomWeightingRouteResourceTest {
         yamlQuery += "" +
                 "priority:\n" +
                 // a faster road (see #2021)? or maybe do both?
-                "  - if: in_area_custom1\n" +
+                "  - if: in_custom1\n" +
                 "    multiply_by: " + priority + "\n" +
                 "areas:\n" +
                 "  custom1:\n" +
diff --git a/web/src/test/resources/com/graphhopper/http/resources/truck.yml b/web/src/test/resources/com/graphhopper/http/resources/truck.yml
index fde62d51d..2fade4ff4 100644
--- a/web/src/test/resources/com/graphhopper/http/resources/truck.yml
+++ b/web/src/test/resources/com/graphhopper/http/resources/truck.yml
@@ -33,7 +33,7 @@ priority:
   - if: toll != NO
     multiply_by: 0.5
   # avoid a certain area
-  - if: in_area_custom1
+  - if: in_custom1
     multiply_by: 0.5
   - if: max_weight < 4.5
     multiply_by: 0