From b3c206bd9447220f68d4cc4b9cbc468689333f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Zbytovsk=C3=BD?= Date: Sat, 17 Jun 2023 16:55:49 +0200 Subject: [PATCH] =?UTF-8?q?tagging:=20add=20iD=20Tagging=20scheme=20to=20a?= =?UTF-8?q?dvanced=20mode=20=F0=9F=8E=89=20(#131)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * iD Tagging scheme exploration * fix build * fix build2 * show only unique fields * add typescript types * y lintfix * find Field for rest of tags * move schema to Feature + add UI * translate values in semiCombo * titles * hack address:* in * remove tags which are already covered by Preset name * experiment - clear sessionstorage * typeCombo is usually cleared by preset.tags * We need typeCombo after all, eg node/7002486683 * move units from label to value * finalize first prototype * allow language switching * fix buildAddress a little * extract PoiDescription.tsx * extract field helpers to fields.ts * refactoring restKeys->keysTodo, added publishDbgObject(), added test * fix access key - covers multiple tags * Use `@openstreetmap/id-tagging-schema` pkg Committed in-flight 5J814 :) * fix keysTodo (never commit 30k ft above ground) * y lintfix * fix [object object] in `subject:wikidata` * update osmApi.test.ts * hide under advanced mode * add Details above TagsTable --- package.json | 1 + src/components/FeaturePanel/FeaturePanel.tsx | 45 +++- src/components/FeaturePanel/FeaturedTags.tsx | 6 - .../FeaturePanel/IdSchemeFields.tsx | 136 +++++++++++ .../ImageSection/ImageSection.tsx | 33 +-- .../ImageSection/PoiDescription.tsx | 46 ++++ src/components/utils/FeatureContext.tsx | 3 + src/helpers/featureLabel.ts | 4 +- src/services/__tests__/osmApi.test.ts | 14 ++ src/services/fetchCache.ts | 5 + src/services/helpers.ts | 35 +-- src/services/intl.tsx | 2 + src/services/osmApi.ts | 7 +- .../tagging/__tests__/idTaggingScheme.test.ts | 64 +++++ src/services/tagging/data.ts | 30 +++ src/services/tagging/fields.ts | 74 ++++++ src/services/tagging/idTaggingScheme.ts | 174 ++++++++++++++ src/services/tagging/presets.ts | 85 +++++++ src/services/tagging/translations.ts | 42 ++++ src/services/tagging/types/Fields.ts | 223 ++++++++++++++++++ src/services/tagging/types/Presets.ts | 97 ++++++++ src/services/types.ts | 2 + src/utils.ts | 9 + yarn.lock | 5 + 24 files changed, 1074 insertions(+), 68 deletions(-) create mode 100644 src/components/FeaturePanel/IdSchemeFields.tsx create mode 100644 src/components/FeaturePanel/ImageSection/PoiDescription.tsx create mode 100644 src/services/tagging/__tests__/idTaggingScheme.test.ts create mode 100644 src/services/tagging/data.ts create mode 100644 src/services/tagging/fields.ts create mode 100644 src/services/tagging/idTaggingScheme.ts create mode 100644 src/services/tagging/presets.ts create mode 100644 src/services/tagging/translations.ts create mode 100644 src/services/tagging/types/Fields.ts create mode 100644 src/services/tagging/types/Presets.ts 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 }) => ( + + + + + ))} + + + {schema.tagsWithFields.map(({ key, value, label, field }) => ( + + + + + ))} + + + {schema.keysTodo.map((key) => ( + + + + + ))} + +
+ {removeUnits(label)} + {addUnits(label, render(field, feature, key, value))}
+ {removeUnits(label)} + {render(field, feature, key, addUnits(label, value))}
{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"