diff --git a/package.json b/package.json
index 16138edb..f1e9bc15 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"@material-ui/core": "^4.11.4",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.58",
+ "@openstreetmap/id-tagging-schema": "^6.1.0",
"@sentry/browser": "^6.5.1",
"@sentry/node": "^6.5.1",
"@types/maplibre-gl": "^1.13.1",
diff --git a/src/components/FeaturePanel/FeaturePanel.tsx b/src/components/FeaturePanel/FeaturePanel.tsx
index 87ad36e3..be0ab3b7 100644
--- a/src/components/FeaturePanel/FeaturePanel.tsx
+++ b/src/components/FeaturePanel/FeaturePanel.tsx
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
+import { Typography } from '@material-ui/core';
import { FeatureHeading } from './FeatureHeading';
import Coordinates from './Coordinates';
import { useToggleState } from '../helpers';
-import { TagsTable } from './TagsTable';
import { getFullOsmappLink, getUrlOsmId } from '../../services/helpers';
import { EditDialog } from './EditDialog/EditDialog';
import {
@@ -21,6 +21,8 @@ import { EditButton } from './EditButton';
import { FeaturedTags } from './FeaturedTags';
import { getLabel } from '../../helpers/featureLabel';
import { ImageSection } from './ImageSection/ImageSection';
+import { IdSchemeFields } from './IdSchemeFields';
+import { TagsTable } from './TagsTable';
const featuredKeys = [
'website',
@@ -69,17 +71,36 @@ const FeaturePanel = () => {
setDialogOpenedWith={setDialogOpenedWith}
/>
-
+ {advanced && (
+
+ )}
+ {!advanced && (
+ <>
+ {featuredTags.length && (
+
+ {t('featurepanel.other_info_heading')}
+
+ )}
+
+ >
+ )}
{advanced && }
diff --git a/src/components/FeaturePanel/FeaturedTags.tsx b/src/components/FeaturePanel/FeaturedTags.tsx
index d9ff4a81..14f37b0c 100644
--- a/src/components/FeaturePanel/FeaturedTags.tsx
+++ b/src/components/FeaturePanel/FeaturedTags.tsx
@@ -1,7 +1,5 @@
-import Typography from '@material-ui/core/Typography';
import React from 'react';
import styled from 'styled-components';
-import { t } from '../../services/intl';
import { FeaturedTag } from './FeaturedTag';
const Spacer = styled.div`
@@ -17,10 +15,6 @@ export const FeaturedTags = ({ featuredTags, setDialogOpenedWith }) => {
))}
-
-
- {t('featurepanel.other_info_heading')}
-
>
);
};
diff --git a/src/components/FeaturePanel/IdSchemeFields.tsx b/src/components/FeaturePanel/IdSchemeFields.tsx
new file mode 100644
index 00000000..d8f2cbd6
--- /dev/null
+++ b/src/components/FeaturePanel/IdSchemeFields.tsx
@@ -0,0 +1,136 @@
+import React, { ReactNode } from 'react';
+import styled from 'styled-components';
+import Typography from '@material-ui/core/Typography';
+import { Field } from '../../services/tagging/types/Fields';
+import { getUrlForTag } from './helpers/getUrlForTag';
+import { slashToOptionalBr } from '../helpers';
+import { buildAddress } from '../../services/helpers';
+import { Feature } from '../../services/types';
+import { t } from '../../services/intl';
+
+// taken from src/components/FeaturePanel/TagsTable.tsx
+const Table = styled.table`
+ font-size: 1rem;
+ width: 100%;
+
+ th,
+ td {
+ padding: 0.1em;
+ overflow: hidden;
+
+ &:hover .show-on-hover {
+ display: block !important;
+ }
+ }
+
+ th {
+ width: 140px;
+ max-width: 140px;
+ color: rgba(0, 0, 0, 0.54);
+ text-align: left;
+ font-weight: normal;
+ vertical-align: baseline;
+ padding-left: 0;
+ }
+
+ table {
+ padding-left: 1em;
+ padding-bottom: 1em;
+ }
+`;
+
+// TODO move to helpers
+const getEllipsisHumanUrl = (humanUrl) => {
+ const MAX_LENGTH = 40;
+ return humanUrl.replace(/^([^/]+.{0,5})(.*)$/, (full, hostname, rest) => {
+ const charsLeft = MAX_LENGTH - 10 - hostname.length;
+ return (
+ hostname +
+ (full.length > MAX_LENGTH
+ ? `…${rest.substring(rest.length - charsLeft)}`
+ : rest)
+ );
+ });
+};
+
+// taken from src/components/FeaturePanel/TagsTable.tsx
+const renderValue = (k, v): string | ReactNode => {
+ const url = getUrlForTag(k, v);
+ if (url) {
+ let humanUrl = v.replace(/^https?:\/\//, '').replace(/^([^/]+)\/$/, '$1');
+ if (k === 'image') {
+ humanUrl = getEllipsisHumanUrl(humanUrl);
+ }
+ return {slashToOptionalBr(humanUrl)};
+ }
+ return v;
+};
+
+const render = (field: Field, feature: Feature, k, v): string | ReactNode => {
+ if (field.type === 'address') {
+ return buildAddress(feature.tags, feature.center);
+ }
+ return renderValue(k, v);
+};
+
+const getTitle = (type: string, field: Field) =>
+ `${type}: ${JSON.stringify(field, null, 2)}`;
+
+// TODO some fields eg. oneway/bicycle doesnt have units in brackets
+const unitRegExp = / \((.+)\)$/i;
+const removeUnits = (label) => label.replace(unitRegExp, '');
+const addUnits = (label, value: string | ReactNode) => {
+ if (typeof value !== 'string') return value;
+ const unit = label.match(unitRegExp);
+ return `${value}${unit ? ` (${unit[1]})` : ''}`;
+};
+
+export const IdSchemeFields = ({ feature, featuredTags }) => {
+ const { schema } = feature;
+ if (!schema) return null;
+ if (!Object.keys(schema).length) return null;
+
+ return (
+ <>
+ {featuredTags.length &&
+ (schema.matchedFields.length ||
+ schema.tagsWithFields.length ||
+ schema.restKeys.length) ? (
+
+ {t('featurepanel.other_info_heading')}
+
+ ) : null}
+
+
+
+ {schema.matchedFields.map(({ key, value, label, field }) => (
+
+
+ {removeUnits(label)}
+ |
+ {addUnits(label, render(field, feature, key, value))} |
+
+ ))}
+
+
+ {schema.tagsWithFields.map(({ key, value, label, field }) => (
+
+
+ {removeUnits(label)}
+ |
+ {render(field, feature, key, addUnits(label, value))} |
+
+ ))}
+
+
+ {schema.keysTodo.map((key) => (
+
+ {key} |
+ {renderValue(key, feature.tags[key])} |
+
+ ))}
+
+
+ >
+ );
+};
diff --git a/src/components/FeaturePanel/ImageSection/ImageSection.tsx b/src/components/FeaturePanel/ImageSection/ImageSection.tsx
index b38045b9..9654dc74 100644
--- a/src/components/FeaturePanel/ImageSection/ImageSection.tsx
+++ b/src/components/FeaturePanel/ImageSection/ImageSection.tsx
@@ -6,11 +6,9 @@ import React from 'react';
import styled from 'styled-components';
import { useFeatureContext } from '../../utils/FeatureContext';
import { FeatureImage } from './FeatureImage';
-import Maki from '../../utils/Maki';
-import { hasName } from '../../../helpers/featureLabel';
import { t } from '../../../services/intl';
import { SHOW_PROTOTYPE_UI } from '../../../config';
-import { Feature } from '../../../services/types';
+import { PoiDescription } from './PoiDescription';
const StyledIconButton = styled(IconButton)`
svg {
@@ -20,40 +18,13 @@ const StyledIconButton = styled(IconButton)`
}
`;
-const PoiType = styled.div`
- color: #fff;
- margin: 0 auto 0 15px;
- font-size: 13px;
- position: relative;
- width: 100%;
- svg {
- vertical-align: bottom;
- }
- span {
- position: absolute;
- left: 20px;
- }
-`;
-
-const getSubclass = ({ layer, osmMeta, properties }: Feature) =>
- properties.subclass?.replace(/_/g, ' ') ||
- (layer && layer.id) || // layer.id specified only when maplibre-gl skeleton displayed
- osmMeta.type;
-
export const ImageSection = () => {
const { feature } = useFeatureContext();
const { properties } = feature;
- const poiType = hasName(feature)
- ? getSubclass(feature)
- : t('featurepanel.no_name');
-
return (
-
-
- {poiType}
-
+
{SHOW_PROTOTYPE_UI && (
<>
diff --git a/src/components/FeaturePanel/ImageSection/PoiDescription.tsx b/src/components/FeaturePanel/ImageSection/PoiDescription.tsx
new file mode 100644
index 00000000..2feeab9b
--- /dev/null
+++ b/src/components/FeaturePanel/ImageSection/PoiDescription.tsx
@@ -0,0 +1,46 @@
+import React from 'react';
+import styled from 'styled-components';
+import { hasName } from '../../../helpers/featureLabel';
+import { useFeatureContext } from '../../utils/FeatureContext';
+import { t } from '../../../services/intl';
+import Maki from '../../utils/Maki';
+import { Feature } from '../../../services/types';
+
+const PoiType = styled.div`
+ color: #fff;
+ margin: 0 auto 0 15px;
+ font-size: 13px;
+ position: relative;
+ width: 100%;
+
+ svg {
+ vertical-align: bottom;
+ }
+
+ span {
+ position: absolute;
+ left: 20px;
+ }
+`;
+
+const getSubclass = ({ layer, osmMeta, properties, schema }: Feature) =>
+ schema?.label ||
+ properties.subclass?.replace(/_/g, ' ') ||
+ (layer && layer.id) || // layer.id specified only when maplibre-gl skeleton displayed
+ osmMeta.type;
+
+export const PoiDescription = () => {
+ const { feature } = useFeatureContext();
+ const { properties } = feature;
+
+ const poiType = hasName(feature)
+ ? getSubclass(feature)
+ : t('featurepanel.no_name');
+
+ return (
+
+
+ {poiType}
+
+ );
+};
diff --git a/src/components/utils/FeatureContext.tsx b/src/components/utils/FeatureContext.tsx
index e520ec02..57c9e842 100644
--- a/src/components/utils/FeatureContext.tsx
+++ b/src/components/utils/FeatureContext.tsx
@@ -9,6 +9,7 @@ import Router from 'next/router';
import Cookies from 'js-cookie';
import { Feature } from '../../services/types';
import { useBoolState } from '../helpers';
+import { publishDbgObject } from '../../utils';
export interface FeatureContextType {
feature: Feature | null;
@@ -43,6 +44,8 @@ export const FeatureProvider = ({
useEffect(() => {
// set feature on next.js router transition
setFeature(featureFromRouter);
+ publishDbgObject('feature', featureFromRouter);
+ publishDbgObject('schema', featureFromRouter?.schema);
}, [featureFromRouter]);
const [homepageShown, showHomepage, hideHomepage] = useBoolState(
diff --git a/src/helpers/featureLabel.ts b/src/helpers/featureLabel.ts
index 765633dd..0115a938 100644
--- a/src/helpers/featureLabel.ts
+++ b/src/helpers/featureLabel.ts
@@ -1,8 +1,8 @@
import { Feature } from '../services/types';
import { roundedToDeg } from '../utils';
-const getSubclass = ({ properties, osmMeta }: Feature) =>
- properties.subclass?.replace(/_/g, ' ') || osmMeta.type; // TODO translate ? maybe use iD editor logic (already with translations)
+const getSubclass = ({ properties, osmMeta, schema }: Feature) =>
+ schema?.label || properties.subclass?.replace(/_/g, ' ') || osmMeta.type;
const getRef = (feature: Feature) =>
feature.tags.ref ? `${getSubclass(feature)} ${feature.tags.ref}` : '';
diff --git a/src/services/__tests__/osmApi.test.ts b/src/services/__tests__/osmApi.test.ts
index 2ab93c2b..d339f3b5 100644
--- a/src/services/__tests__/osmApi.test.ts
+++ b/src/services/__tests__/osmApi.test.ts
@@ -9,15 +9,29 @@ import {
way,
wayFeature,
} from './osmApi.fixture';
+import { intl } from '../intl';
+import * as tagging from '../tagging/translations';
+import * as idTaggingScheme from '../tagging/idTaggingScheme';
const osm = (item) => ({ elements: [item] });
const overpass = {
elements: [{ center: { lat: 50, lon: 14 } }],
};
+// fetchFeature() fetches the translations for getSchemaForFeature()
+// TODO maybe refactor it without need for intl?
+intl.lang = 'en';
+jest.mock('next/config', () => () => ({
+ publicRuntimeConfig: { languages: ['en'] },
+}));
+
describe('fetchFeature', () => {
beforeEach(() => {
jest.clearAllMocks();
+ jest.spyOn(tagging, 'fetchSchemaTranslations').mockResolvedValue(true);
+ jest
+ .spyOn(idTaggingScheme, 'getSchemaForFeature')
+ .mockReturnValue(undefined); // this is covered in idTaggingScheme.test.ts
});
const isServer = jest.spyOn(helpers, 'isServer').mockReturnValue(true);
diff --git a/src/services/fetchCache.ts b/src/services/fetchCache.ts
index 4125c0bf..5a4ac281 100644
--- a/src/services/fetchCache.ts
+++ b/src/services/fetchCache.ts
@@ -7,6 +7,7 @@ const fetchCache = isBrowser()
get: (key) => sessionStorage.getItem(key),
remove: (key) => sessionStorage.removeItem(key),
put: (key, value) => sessionStorage.setItem(key, value),
+ clear: () => sessionStorage.clear(),
}
: {
get: (key) => cache[key],
@@ -14,6 +15,7 @@ const fetchCache = isBrowser()
put: (key, value) => {
cache[key] = value;
},
+ clear: () => {},
};
export const getKey = (url, opts) => url + JSON.stringify(opts);
@@ -28,6 +30,9 @@ export const writeCacheSafe = (key, value) => {
try {
fetchCache.put(key, value);
} catch (e) {
+ if (e.message.includes('exceeded the quota')) {
+ fetchCache.clear();
+ }
console.warn(`Item ${key} was not saved to cache: `, e); // eslint-disable-line no-console
}
};
diff --git a/src/services/helpers.ts b/src/services/helpers.ts
index 5d0773a2..7fc11e83 100644
--- a/src/services/helpers.ts
+++ b/src/services/helpers.ts
@@ -88,40 +88,43 @@ export const isValidImage = (url): Promise => {
export const stringifyDomXml = (itemXml) =>
isString(itemXml) ? itemXml : new XMLSerializer().serializeToString(itemXml);
-// TODO better mexico border + add Australia, New Zealand & South Africa
+// TODO better mexico border + add Australia, New Zealand & South Africa
const polygonUsCan = [[-143, 36], [-117, 32], [-96, 25], [-50, 19], [-56, 71], [-175, 70], [-143, 36]]; // prettier-ignore
-const isInside = ([x, y]: Position, vs) => {
- // ray-casting algorithm based on
- // https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html/pnpoly.html
+const isInside = ([x, y]: Position, points) => {
+ // ray-casting algorithm based on https://wrf.ecse.rpi.edu/Research/Short_Notes/pnpoly.html
let inside = false;
- // eslint-disable-next-line no-plusplus
- for (let i = 0, j = vs.length - 1; i < vs.length; j = i++) {
- const [xi, yi] = vs[i];
- const [xj, yj] = vs[j];
+ for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
+ const [xi, yi] = points[i];
+ const [xj, yj] = points[j];
const intersect =
yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
if (intersect) inside = !inside;
}
return inside;
};
+const isNumberFirst = (loc: Position) => loc && isInside(loc, polygonUsCan);
export const buildAddress = (
{
'addr:place': place,
'addr:street': street,
'addr:housenumber': hnum,
- 'addr:conscriptionnumber': num1, // czech/slovak/hungary
- 'addr:streetnumber': num2,
+ 'addr:conscriptionnumber': cnum, // czech/slovak/hungary
+ 'addr:streetnumber': snum,
'addr:city': city,
+ 'addr:state': state,
+ 'addr:postcode': postcode,
}: Record,
- loc: Position = undefined,
+ loc?: Position,
) => {
- if (loc && isInside(loc, polygonUsCan)) {
- return join(join(hnum ?? num2, ' ', street ?? place), ', ', city);
- }
+ const number = hnum ?? join(cnum, '/', snum);
+ const streetPlace = street ?? place;
+
return join(
- join(street ?? place, ' ', hnum ?? join(num1, '/', num2)),
+ isNumberFirst(loc)
+ ? join(number, ' ', streetPlace)
+ : join(streetPlace, ' ', number),
', ',
- city,
+ join(join(postcode, ' ', city), ', ', state),
);
};
diff --git a/src/services/intl.tsx b/src/services/intl.tsx
index 2b3efdbe..9e417086 100644
--- a/src/services/intl.tsx
+++ b/src/services/intl.tsx
@@ -4,6 +4,7 @@ import Router from 'next/router';
import { MessagesType, TranslationId } from './types';
import { isBrowser, isServer } from '../components/helpers';
import { getServerIntl } from './intlServer';
+import { publishDbgObject } from '../utils';
type Values = { [variable: string]: string };
@@ -50,6 +51,7 @@ export const setIntl = (initialIntl: Intl) => {
if (initialIntl) {
intl.lang = initialIntl.lang;
intl.messages = initialIntl.messages;
+ publishDbgObject('intl', intl);
}
};
diff --git a/src/services/osmApi.ts b/src/services/osmApi.ts
index 3536f4d6..442a4ce2 100644
--- a/src/services/osmApi.ts
+++ b/src/services/osmApi.ts
@@ -5,6 +5,8 @@ import { removeFetchCache } from './fetchCache';
import { overpassAroundToSkeletons } from './overpassAroundToSkeletons';
import { getPoiClass } from './getPoiClass';
import { isBrowser } from '../components/helpers';
+import { getSchemaForFeature } from './tagging/idTaggingScheme';
+import { fetchSchemaTranslations } from './tagging/translations';
const getOsmUrl = ({ type, id }) =>
`https://www.openstreetmap.org/api/0.6/${type}/${id}.json`;
@@ -98,6 +100,7 @@ export const fetchFeature = async (shortId): Promise => {
const [element, center] = await Promise.all([
getOsmPromise(apiId),
getCenterPromise(apiId),
+ fetchSchemaTranslations(), // TODO this should be mocked in test??? could be moved to setIntl or something
]);
const feature = osmToFeature(element);
@@ -105,7 +108,9 @@ export const fetchFeature = async (shortId): Promise => {
feature.center = center;
}
- return feature;
+ const schema = getSchemaForFeature(feature); // TODO forward lang here ?? maybe full intl?
+ console.log('schema', schema); // eslint-disable-line no-console
+ return { ...feature, schema };
} catch (e) {
console.error(`fetchFeature(${shortId}):`, e); // eslint-disable-line no-console
diff --git a/src/services/tagging/__tests__/idTaggingScheme.test.ts b/src/services/tagging/__tests__/idTaggingScheme.test.ts
new file mode 100644
index 00000000..943b9cbe
--- /dev/null
+++ b/src/services/tagging/__tests__/idTaggingScheme.test.ts
@@ -0,0 +1,64 @@
+import translations from '@openstreetmap/id-tagging-schema/dist/translations/en.json';
+import { getSchemaForFeature } from '../idTaggingScheme';
+import { Feature } from '../../types';
+import { mockSchemaTranslations } from '../translations';
+import { intl } from '../../intl';
+
+intl.lang = 'en';
+
+jest.mock('next/config', () => () => ({
+ publicRuntimeConfig: { languages: ['en'] },
+}));
+
+const feature = {
+ osmMeta: { type: 'way' },
+ tags: {
+ bicycle: 'no',
+ bridge: 'yes',
+ foot: 'no',
+ hgv: 'designated',
+ highway: 'motorway',
+ horse: 'no',
+ lanes: '2',
+ layer: '1',
+ lit: 'no',
+ maxspeed: '55 mph',
+ oneway: 'yes',
+ ref: 'I 84',
+ surface: 'asphalt',
+ 'tiger:cfcc': 'A15',
+ 'tiger:county': 'Orange, NY',
+ 'tiger:name_base': 'I-84',
+ },
+} as unknown as Feature;
+
+describe('idTaggingScheme', () => {
+ it('should multiple access', () => {
+ mockSchemaTranslations(translations);
+
+ const result = getSchemaForFeature(feature);
+ expect(result.label).toBe('Motorway');
+ expect(result.presetKey).toBe('highway/motorway');
+ expect(result.matchedFields).toMatchObject([
+ { label: 'Road Number', value: 'I 84' },
+ { label: 'One Way', value: 'Yes' },
+ { label: 'Speed Limit', value: '55 mph' },
+ { label: 'Lanes', value: '2' },
+ { label: 'Surface', value: 'Asphalt' },
+ {
+ label: 'Allowed Access',
+ value: 'Foot: Prohibited,\nBicycles: Prohibited,\nHorses: Prohibited',
+ },
+ { label: 'Lit', value: 'no' },
+ ]);
+ expect(result.tagsWithFields).toMatchObject([
+ { label: 'Layer', value: '1' },
+ ]);
+ expect(result.keysTodo).toMatchObject([
+ 'hgv',
+ 'tiger:cfcc',
+ 'tiger:county',
+ 'tiger:name_base',
+ ]);
+ });
+});
diff --git a/src/services/tagging/data.ts b/src/services/tagging/data.ts
new file mode 100644
index 00000000..52e0fad7
--- /dev/null
+++ b/src/services/tagging/data.ts
@@ -0,0 +1,30 @@
+import fieldsJson from '@openstreetmap/id-tagging-schema/dist/fields.json';
+import presetsJson from '@openstreetmap/id-tagging-schema/dist/presets.json';
+import { Fields } from './types/Fields';
+import { Presets } from './types/Presets';
+import { publishDbgObject } from '../../utils';
+
+export const fields = fieldsJson as unknown as Fields;
+
+Object.keys(fieldsJson).forEach((fieldKey) => {
+ fields[fieldKey].fieldKey = fieldKey;
+});
+
+export const presets = presetsJson as unknown as Presets;
+Object.keys(presetsJson).forEach((presetKey) => {
+ presets[presetKey].presetKey = presetKey;
+});
+
+publishDbgObject('presets', presets);
+publishDbgObject('fields', fields);
+
+// TODO build a key lookup table for fields by osm key ?
+// const fieldsByOsmKey = {};
+// Object.entries(fields).forEach(([fieldKey, field]) => {
+// if (field.key) {
+// fieldsByOsmKey[field.key] = fieldKey;
+// }
+// if (field.keys) {
+// field.keys.forEach((key) => (fieldsByOsmKey[key] = fieldKey));
+// }
+// });
diff --git a/src/services/tagging/fields.ts b/src/services/tagging/fields.ts
new file mode 100644
index 00000000..bd531033
--- /dev/null
+++ b/src/services/tagging/fields.ts
@@ -0,0 +1,74 @@
+// links like {shop}, are recursively resolved to their fields
+import { Preset } from './types/Presets';
+import { presets } from './data';
+
+const getResolvedFields = (fieldKeys: string[]): string[] =>
+ fieldKeys.flatMap((key) => {
+ if (key.match(/^{.*}$/)) {
+ const presetKey = key.substr(1, key.length - 2);
+ return getResolvedFields(presets[presetKey].fields); // TODO does "{shop}" links to preset's fields or moreFields?
+ }
+ return key;
+ });
+
+const getResolvedFieldsWithParents = (
+ preset: Preset,
+ fieldType: 'fields' | 'moreFields',
+): string[] => {
+ const parts = preset.presetKey.split('/');
+
+ if (parts.length > 1) {
+ const parentKey = parts.slice(0, parts.length - 1).join('/');
+ const parentPreset = presets[parentKey];
+ if (parentPreset) {
+ return [
+ ...getResolvedFieldsWithParents(parentPreset, fieldType),
+ ...(preset[fieldType] ?? []),
+ ];
+ }
+ }
+
+ return preset[fieldType] ?? [];
+};
+
+export const computeAllFieldKeys = (preset: Preset) => {
+ const allFieldKeys = [
+ ...getResolvedFields(getResolvedFieldsWithParents(preset, 'fields')),
+ ...getResolvedFields(getResolvedFieldsWithParents(preset, 'moreFields')),
+ ];
+
+ // @ts-ignore
+ return [...new Set(allFieldKeys)];
+};
+
+export const getValueForField = (
+ field,
+ fieldTranslation,
+ value: string,
+ tagsForField = [],
+) => {
+ if (field.type === 'semiCombo') {
+ return value
+ .split(';')
+ .map((v) => fieldTranslation?.options?.[v] ?? v)
+ .join(',\n');
+ }
+ // eg field.type === 'access' or 'structure'
+ if (fieldTranslation?.types && fieldTranslation?.options) {
+ return tagsForField
+ .map(
+ ({ key, value: value2 }) =>
+ `${fieldTranslation.types[key]}: ${fieldTranslation.options[value2]?.title}`,
+ )
+ .join(',\n');
+ }
+
+ // TODO this is not correct
+ if (tagsForField.length >= 2) {
+ return tagsForField
+ .map(({ key, value: value2 }) => `${key}: ${value2}`)
+ .join(',\n');
+ }
+
+ return fieldTranslation?.options?.[value] ?? value;
+};
diff --git a/src/services/tagging/idTaggingScheme.ts b/src/services/tagging/idTaggingScheme.ts
new file mode 100644
index 00000000..22ddf69c
--- /dev/null
+++ b/src/services/tagging/idTaggingScheme.ts
@@ -0,0 +1,174 @@
+import { Feature } from '../types';
+import { getFieldTranslation, getPresetTranslation } from './translations';
+import { getPresetForFeature } from './presets';
+import { fields } from './data';
+import { computeAllFieldKeys, getValueForField } from './fields';
+import { Preset } from './types/Presets';
+import { publishDbgObject } from '../../utils';
+
+// TODO move to shared place
+const featuredKeys = [
+ 'name', // this is not in the other place
+ 'website',
+ 'contact:website',
+ 'phone',
+ 'contact:phone',
+ 'contact:mobile',
+ 'opening_hours',
+ 'description',
+];
+
+const matchFieldsFromPreset = (
+ preset: Preset,
+ keysTodo: any,
+ feature: Feature,
+) => {
+ const computedAllFieldKeys = computeAllFieldKeys(preset);
+ publishDbgObject('computedAllFieldKeys', computedAllFieldKeys);
+
+ return computedAllFieldKeys
+ .map((fieldKey: string) => {
+ const field = fields[fieldKey];
+ const key = field?.key;
+ const keys = field?.keys;
+ const shouldWeIncludeThisField =
+ keysTodo.has(key) || keysTodo.hasAny(keys);
+ if (!shouldWeIncludeThisField) {
+ return {};
+ }
+ if (field.type === 'typeCombo') {
+ keysTodo.remove(field.key); // ignore eg. railway=tram_stop on public_transport=stop_position
+ return {};
+ }
+
+ const value = feature.tags[key];
+
+ const keysInField = [
+ ...(field.keys ?? []),
+ ...(field.key ? [field.key] : []),
+ ];
+ const tagsForField = [];
+ keysInField.forEach((k) => {
+ if (feature.tags[k]) {
+ tagsForField.push({ key: k, value: feature.tags[k] });
+ }
+ keysTodo.remove(k); // remove all "address:*" keys etc.
+ });
+
+ const fieldTranslation = getFieldTranslation(field);
+
+ return {
+ key,
+ value: getValueForField(field, fieldTranslation, value, tagsForField),
+ field,
+ tagsForField,
+ fieldTranslation,
+ label: fieldTranslation?.label ?? field.label,
+ };
+ })
+ .filter((field) => field.value);
+};
+
+const matchRestToFields = (keysTodo: any, feature: Feature) =>
+ keysTodo
+ .map((key) => {
+ const field = Object.values(fields).find(
+ (f) => f.key === key || f.keys?.includes(key),
+ ); // todo cache this
+ if (!field) {
+ return {};
+ }
+ if (field.type === 'typeCombo') {
+ keysTodo.remove(field.key); // ignore eg. railway=tram_stop on public_transport=stop_position
+ return {};
+ }
+
+ const value = feature.tags[key];
+
+ const keysInField = [
+ ...(field.keys ?? []),
+ ...(field.key ? [field.key] : []),
+ ];
+ const tagsForField = [];
+ keysInField.forEach((k) => {
+ if (feature.tags[k]) {
+ tagsForField.push({ key: k, value: feature.tags[k] });
+ }
+ keysTodo.remove(k); // remove all "address:*" keys etc.
+ });
+
+ const fieldTranslation = getFieldTranslation(field);
+
+ return {
+ key,
+ value: getValueForField(field, fieldTranslation, value, tagsForField),
+ field,
+ tagsForField,
+ fieldTranslation,
+ label: fieldTranslation?.label ?? field.label ?? `[${key}]`,
+ };
+ })
+ .filter((field) => field.field);
+
+const keysTodo = {
+ state: [],
+ init(feature) {
+ this.state = Object.keys(feature.tags).filter(
+ (key) => !featuredKeys.includes(key),
+ );
+ },
+ resolve(tags) {
+ Object.keys(tags).forEach((key) => {
+ this.state.splice(this.state.indexOf(key), 1);
+ });
+ },
+ has(key) {
+ return this.state.includes(key);
+ },
+ hasAny(keys) {
+ return keys?.some((key) => this.state.includes(key));
+ },
+ remove(key) {
+ const index = this.state.indexOf(key);
+ if (index > -1) {
+ this.state.splice(index, 1);
+ }
+ },
+ resolveFields(fieldsArray) {
+ fieldsArray.forEach((field) => {
+ if (field?.field?.key) {
+ this.remove(field.field.key);
+ }
+ if (field?.field?.keys) {
+ field.field.keys.forEach((key) => this.remove(key));
+ }
+ });
+ },
+ map(fn) {
+ return this.state.map(fn);
+ },
+};
+
+export const getSchemaForFeature = (feature: Feature) => {
+ const preset = getPresetForFeature(feature);
+
+ keysTodo.init(feature);
+ keysTodo.resolve(preset.tags); // remove tags which are already covered by Preset keys
+
+ const matchedFields = matchFieldsFromPreset(preset, keysTodo, feature);
+ keysTodo.resolveFields(matchedFields);
+
+ const tagsWithFields = matchRestToFields(keysTodo, feature);
+ keysTodo.resolveFields(tagsWithFields);
+
+ // TODO fix one field with more tags! like address
+ return {
+ presetKey: preset.presetKey,
+ preset,
+ feature,
+ label: getPresetTranslation(preset.presetKey),
+ matchedFields,
+ tagsWithFields,
+ keysTodo: keysTodo.state,
+ };
+};
diff --git a/src/services/tagging/presets.ts b/src/services/tagging/presets.ts
new file mode 100644
index 00000000..b1ce93f9
--- /dev/null
+++ b/src/services/tagging/presets.ts
@@ -0,0 +1,85 @@
+import { presets } from './data';
+import { Feature } from '../types';
+import { Preset } from './types/Presets';
+
+// taken from iD codebase https://github.com/openstreetmap/iD/blob/dd30a39d7487e1084396712ce861f4b6c5a07849/modules/presets/preset.js#L61
+// _this is "preset" object with originalScore set
+const matchScore = (_this, entityTags) => {
+ /* eslint-disable no-restricted-syntax,guard-for-in */
+ const { tags } = _this;
+ const seen = {};
+ let score = 0;
+
+ // match on tags
+ for (const k in tags) {
+ seen[k] = true;
+ if (entityTags[k] === tags[k]) {
+ score += _this.originalScore;
+ } else if (tags[k] === '*' && k in entityTags) {
+ score += _this.originalScore / 2;
+ } else {
+ return -1;
+ }
+ }
+
+ // boost score for additional matches in addTags - #6802
+ const { addTags } = _this;
+ for (const k in addTags) {
+ if (!seen[k] && entityTags[k] === addTags[k]) {
+ score += _this.originalScore;
+ }
+ }
+
+ if (_this.searchable === false) {
+ score *= 0.999;
+ }
+
+ return score;
+ /* eslint-enable no-restricted-syntax,guard-for-in */
+};
+
+const index = {
+ node: [],
+ way: [],
+ relation: [],
+};
+
+// build an index by geometry type
+Object.values(presets).forEach((preset) => {
+ const { geometry } = preset;
+
+ geometry.forEach((geometryType) => {
+ const record = {
+ originalScore: (preset as any).matchScore ?? 1,
+ ...preset,
+ };
+
+ // OsmAPP can't distinguish between points and vertices
+ if (geometryType === 'point' || geometryType === 'vertex') {
+ index.node.push(record);
+ } else if (geometryType === 'line') {
+ index.way.push(record);
+ } else if (geometryType === 'area') {
+ index.way.push(record);
+ index.relation.push(record);
+ } else if (geometryType === 'relation') {
+ index.relation.push(record);
+ }
+ });
+});
+
+// inspired by _this.matchTags() in iD codebase
+export const getPresetForFeature = (feature: Feature): Preset => {
+ const { tags, osmMeta } = feature;
+ const { type } = osmMeta;
+ const candidates = [];
+
+ index[type].forEach((candidate) => {
+ const score = matchScore(candidate, tags);
+ if (score !== -1) {
+ candidates.push({ score, candidate });
+ }
+ });
+
+ return candidates.sort((a, b) => b.score - a.score)[0].candidate;
+};
diff --git a/src/services/tagging/translations.ts b/src/services/tagging/translations.ts
new file mode 100644
index 00000000..16800b41
--- /dev/null
+++ b/src/services/tagging/translations.ts
@@ -0,0 +1,42 @@
+import { fetchJson } from '../fetch';
+import { Field } from './types/Fields';
+import { intl } from '../intl';
+import { publishDbgObject } from '../../utils';
+
+// https://cdn.jsdelivr.net/npm/@openstreetmap/id-tagging-schema@6.1.0/dist/translations/en.min.json
+const cdnUrl = `https://cdn.jsdelivr.net/npm/@openstreetmap/id-tagging-schema`;
+
+// TODO downloa up-to-date or use node_module?
+let translations = {};
+export const fetchSchemaTranslations = async () => {
+ if (translations[intl.lang]) return;
+
+ const presetsPackage = await fetchJson(`${cdnUrl}/package.json`);
+ const { version } = presetsPackage;
+
+ // this request is cached in browser
+ translations = await fetchJson(
+ `${cdnUrl}@${version}/dist/translations/${intl.lang}.min.json`,
+ );
+ publishDbgObject('schemaTranslations', translations);
+};
+
+export const mockSchemaTranslations = (mockTranslations) => {
+ translations = mockTranslations;
+};
+
+export const getPresetTranslation = (key: string) =>
+ translations ? translations[intl.lang].presets.presets[key].name : undefined;
+
+export const getFieldTranslation = (field: Field) => {
+ if (!translations) return undefined;
+
+ if (field.label?.match(/^{.*}$/)) {
+ const resolved = field.label.substr(1, field.label.length - 2);
+ return translations[intl.lang].presets.fields[resolved];
+ }
+
+ // The id 169522276 is different for each intl.language :(
+ // https://www.transifex.com/openstreetmap/id-editor/translate/#cs/presets/169522276?q=key%3Apresets.fields.XXX
+ return translations[intl.lang].presets.fields[field.fieldKey];
+};
diff --git a/src/services/tagging/types/Fields.ts b/src/services/tagging/types/Fields.ts
new file mode 100644
index 00000000..38fd9add
--- /dev/null
+++ b/src/services/tagging/types/Fields.ts
@@ -0,0 +1,223 @@
+// https://github.com/ideditor/schema-builder/blob/main/schemas/field.json
+
+type FieldType =
+ | 'access'
+ | 'address'
+ | 'check'
+ | 'colour'
+ | 'combo'
+ | 'date'
+ | 'defaultCheck'
+ | 'directionalCombo'
+ | 'email'
+ | 'identifier'
+ | 'lanes'
+ | 'localized'
+ | 'manyCombo'
+ | 'multiCombo'
+ | 'networkCombo'
+ | 'number'
+ | 'onewayCheck'
+ | 'radio'
+ | 'restrictions'
+ | 'roadheight'
+ | 'roadspeed'
+ | 'semiCombo'
+ | 'structureRadio'
+ | 'tel'
+ | 'text'
+ | 'textarea'
+ | 'typeCombo'
+ | 'url'
+ | 'wikidata'
+ | 'wikipedia';
+
+/**
+ * A reusable form element for presets
+ */
+export type Field = {
+ // added by osmapp (not in schema)
+ fieldKey: string;
+
+ /**
+ * Tag key whose value is to be displayed
+ */
+ key?: string;
+ /**
+ * Tag keys whose value is to be displayed
+ */
+ keys?: string[];
+ /**
+ * Taginfo documentation parameters (to be used when a field manages multiple tags)
+ */
+ reference?:
+ | {
+ /**
+ * For documentation of a key
+ */
+ key?: string;
+ /**
+ * For documentation of a tag (key and value)
+ */
+ value?: string;
+ }
+ | {
+ /**
+ * For documentation of a relation type
+ */
+ rtype?: string;
+ };
+ /**
+ * Type of field
+ */
+ type?: FieldType;
+ /**
+ * English label for the field caption. A field can reference the label of another by using that field's identifier contained in brackets (e.g. {field}), in which case also the field's terms will be referenced from that field.
+ */
+ label?: string;
+ /**
+ * If specified, only show the field for these kinds of geometry
+ */
+ geometry?: [
+ 'point' | 'vertex' | 'line' | 'area' | 'relation', // minimal one entry
+ ...('point' | 'vertex' | 'line' | 'area' | 'relation')[]
+ ];
+ /**
+ * The default value for this field
+ */
+ default?: string;
+ /**
+ * List of untranslatable string suggestions (combo fields)
+ */
+ options?: string[];
+ /**
+ * If true, the top values from TagInfo will be suggested in the dropdown (combo fields only)
+ */
+ autoSuggestions?: boolean;
+ /**
+ * If true, the user can type their own value in addition to any listed in `options` or `strings.options` (combo fields only)
+ */
+ customValues?: boolean;
+ /**
+ * If true, this field will appear in the Add Field list for all presets
+ */
+ universal?: boolean;
+ /**
+ * Placeholder text for this field. A field can reference the placeholder text of another by using that field's identifier contained in brackets, like {field}.
+ */
+ placeholder?: string;
+ /**
+ * Strings sent to transifex for translation
+ */
+ strings?: {
+ /**
+ * Translatable options (combo fields).
+ */
+ options?: {
+ [k: string]: unknown;
+ };
+ /**
+ * Specialized fields can request translation of arbitrary strings
+ */
+ [k: string]: {
+ [k: string]: unknown;
+ };
+ };
+ /**
+ * A field can reference strings of another by using that field's identifier contained in brackets, like {field}.
+ */
+ stringsCrossReference?: string;
+ /**
+ * If true, replace spaces with underscores in the tag value (combo fields only)
+ */
+ snake_case?: boolean;
+ /**
+ * If true, allow case sensitive field values (combo fields only)
+ */
+ caseSensitive?: boolean;
+ /**
+ * Minimum field value (number fields only)
+ */
+ minValue?: number;
+ /**
+ * Maximum field value (number fields only)
+ */
+ maxValue?: number;
+ /**
+ * The amount the stepper control should add or subtract (number fields only)
+ */
+ increment?: number;
+ /**
+ * Tagging constraint for showing this field in the editor
+ */
+ prerequisiteTag?:
+ | {
+ /**
+ * The key of the required tag
+ */
+ key: string;
+ }
+ | {
+ /**
+ * The key of the required tag
+ */
+ key: string;
+ /**
+ * The value that the tag must have. (alternative to 'valueNot')
+ */
+ value: string;
+ }
+ | {
+ /**
+ * The key of the required tag
+ */
+ key: string;
+ /**
+ * The value that the tag cannot have. (alternative to 'value')
+ */
+ valueNot: string;
+ }
+ | {
+ /**
+ * A key that must not be present
+ */
+ keyNot: string;
+ };
+ /**
+ * English synonyms or related search terms
+ */
+ terms?: string[];
+ /**
+ * An object specifying the IDs of regions where this field is or isn't valid. See: https://github.com/ideditor/location-conflation
+ */
+ locationSet?: {
+ include?: string[];
+ exclude?: string[];
+ };
+ /**
+ * Permalink URL for `identifier` fields. Must contain a {value} placeholder
+ */
+ urlFormat?: string;
+ /**
+ * Regular expression that a valid `identifier` value is expected to match
+ */
+ pattern?: string;
+ /**
+ * The manner and context in which the field is used
+ */
+ usage?: 'preset' | 'changeset' | 'manual' | 'group';
+ /**
+ * For combo fields: Name of icons which represents different values of this field
+ */
+ icons?: {
+ [k: string]: unknown;
+ };
+ /**
+ * A field can reference icons of another by using that field's identifier contained in brackets, like {field}.
+ */
+ iconsCrossReference?: string;
+};
+
+export type Fields = {
+ [fieldKey: string]: Field;
+};
diff --git a/src/services/tagging/types/Presets.ts b/src/services/tagging/types/Presets.ts
new file mode 100644
index 00000000..a9678869
--- /dev/null
+++ b/src/services/tagging/types/Presets.ts
@@ -0,0 +1,97 @@
+/**
+ * Associates an icon, form fields, and other UI with a set of OSM tags
+ */
+export interface Preset {
+ // added by osmapp (not in schema)
+ presetKey: string;
+
+ /**
+ * The English name for the feature. A preset can reference the label of another by using that preset's identifier contained in brackets (e.g. {preset}), in which case also the preset's aliases and terms will also be referenced from that preset.
+ */
+ name?: string;
+ /**
+ * Valid geometry types for the feature, in order of preference
+ */
+ geometry?: [
+ 'point' | 'vertex' | 'line' | 'area' | 'relation',
+ ...('point' | 'vertex' | 'line' | 'area' | 'relation')[]
+ ];
+ /**
+ * Tags that must be present for the preset to match
+ */
+ tags?: {
+ [k: string]: string;
+ };
+ /**
+ * Tags that are added when changing to the preset (default is the same value as 'tags')
+ */
+ addTags?: {
+ [k: string]: string;
+ };
+ /**
+ * Tags that are removed when changing to another preset (default is the same value as 'addTags' which in turn defaults to 'tags')
+ */
+ removeTags?: {
+ [k: string]: string;
+ };
+ /**
+ * Default form fields that are displayed for the preset. A preset can reference the fields of another by using that preset's identifier contained in brackets, like {preset}.
+ */
+ fields?: string[];
+ /**
+ * Additional form fields that can be attached with the 'Add field' dropdown. A preset can reference the "moreFields" of another by using that preset's identifier contained in brackets, like {preset}.
+ */
+ moreFields?: string[];
+ /**
+ * Name of preset icon which represents this preset
+ */
+ icon?: string;
+ /**
+ * The URL of a remote image that is more specific than 'icon'
+ */
+ imageURL?: string;
+ /**
+ * English search terms or related keywords
+ */
+ terms?: string[];
+ /**
+ * Display-ready English synonyms for the `name`
+ */
+ aliases?: string[];
+ /**
+ * Whether or not the preset will be suggested via search
+ */
+ searchable?: boolean;
+ /**
+ * The quality score this preset will receive when being compared with other matches (higher is better)
+ */
+ matchScore?: number;
+ /**
+ * Taginfo documentation parameters (to be used when a preset manages multiple tags)
+ */
+ reference?: {
+ /**
+ * For documentation of a key
+ */
+ key?: string;
+ /**
+ * For documentation of a tag (key and value)
+ */
+ value?: string;
+ };
+ /**
+ * The ID of a preset that is preferable to this one
+ */
+ replacement?: string;
+ /**
+ * An object specifying the IDs of regions where this preset is or isn't valid. See: https://github.com/ideditor/location-conflation
+ */
+ locationSet?: {
+ include?: string[];
+ exclude?: string[];
+ };
+}
+
+export type Presets = {
+ [presetKey: string]: Preset;
+};
diff --git a/src/services/types.ts b/src/services/types.ts
index 4e5832fe..e4810b70 100644
--- a/src/services/types.ts
+++ b/src/services/types.ts
@@ -1,4 +1,5 @@
import type Vocabulary from '../locales/vocabulary';
+import type { getSchemaForFeature } from './tagging/idTaggingScheme';
export interface ImageUrls {
source?: string;
@@ -74,6 +75,7 @@ export interface Feature {
roundedCenter?: LonLatRounded;
ssrFeatureImage?: Image;
error?: 'deleted' | 'network' | 'unknown' | '404' | '500'; // etc.
+ schema?: ReturnType;
// skeleton
layer?: { id: string };
diff --git a/src/utils.ts b/src/utils.ts
index 891f96ac..ab1d7f39 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -53,3 +53,12 @@ export const getUtfStrikethrough = (text) =>
.join('');
export const join = (a, sep, b) => `${a || ''}${a && b ? sep : ''}${b || ''}`;
+
+export const publishDbgObject = (key, value) => {
+ if (typeof window !== 'undefined') {
+ // @ts-ignore
+ if (!window.dbg) window.dbg = {};
+ // @ts-ignore
+ window.dbg[key] = value;
+ }
+};
diff --git a/yarn.lock b/yarn.lock
index 46c72818..21de4b3c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1795,6 +1795,11 @@
"@nodelib/fs.scandir" "2.1.5"
fastq "^1.6.0"
+"@openstreetmap/id-tagging-schema@^6.1.0":
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/@openstreetmap/id-tagging-schema/-/id-tagging-schema-6.3.0.tgz#4e58045a2489fc3aedff680757f2676a7894c490"
+ integrity sha512-Qe8cHI58bya8AjbVmWicPxwaJE7xq98ffcZ/M7AwYdM2QbxfsQHooBjh1HYBpxSWgd9JVT6d3g4uaUYftvyhtw==
+
"@rollup/plugin-babel@^5.2.0":
version "5.3.1"
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz#04bc0608f4aa4b2e4b1aebf284344d0f68fda283"