Skip to content

Commit

Permalink
tagging: add iD Tagging scheme to advanced mode 🎉 (#131)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
zbycz authored Jun 17, 2023
1 parent 5db8b28 commit b3c206b
Show file tree
Hide file tree
Showing 24 changed files with 1,074 additions and 68 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
45 changes: 33 additions & 12 deletions src/components/FeaturePanel/FeaturePanel.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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',
Expand Down Expand Up @@ -69,17 +71,36 @@ const FeaturePanel = () => {
setDialogOpenedWith={setDialogOpenedWith}
/>

<TagsTable
tags={tags}
center={feature.center}
except={
advanced || deleted ? [] : ['name', 'layer', ...featuredKeys]
}
onEdit={setDialogOpenedWith}
key={
getUrlOsmId(osmMeta) // we need to refresh inner state
}
/>
{advanced && (
<IdSchemeFields
featuredTags={deleted ? [] : featuredTags}
feature={feature}
/>
)}
{!advanced && (
<>
{featuredTags.length && (
<Typography
variant="overline"
display="block"
color="textSecondary"
>
{t('featurepanel.other_info_heading')}
</Typography>
)}
<TagsTable
tags={tags}
center={feature.center}
except={
advanced || deleted ? [] : ['name', 'layer', ...featuredKeys]
}
onEdit={setDialogOpenedWith}
key={
getUrlOsmId(osmMeta) // we need to refresh inner state
}
/>
</>
)}

{advanced && <Members />}

Expand Down
6 changes: 0 additions & 6 deletions src/components/FeaturePanel/FeaturedTags.tsx
Original file line number Diff line number Diff line change
@@ -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`
Expand All @@ -17,10 +15,6 @@ export const FeaturedTags = ({ featuredTags, setDialogOpenedWith }) => {
<FeaturedTag key={k} k={k} v={v} onEdit={setDialogOpenedWith} />
))}
<Spacer />

<Typography variant="overline" display="block" color="textSecondary">
{t('featurepanel.other_info_heading')}
</Typography>
</>
);
};
136 changes: 136 additions & 0 deletions src/components/FeaturePanel/IdSchemeFields.tsx
Original file line number Diff line number Diff line change
@@ -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 <a href={url}>{slashToOptionalBr(humanUrl)}</a>;
}
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) ? (
<Typography variant="overline" display="block" color="textSecondary">
{t('featurepanel.other_info_heading')}
</Typography>
) : null}

<Table>
<tbody>
{schema.matchedFields.map(({ key, value, label, field }) => (
<tr key={key}>
<th title={getTitle('from preset', field)}>
{removeUnits(label)}
</th>
<td>{addUnits(label, render(field, feature, key, value))}</td>
</tr>
))}
</tbody>
<tbody>
{schema.tagsWithFields.map(({ key, value, label, field }) => (
<tr key={key}>
<th title={getTitle('standalone field', field)}>
{removeUnits(label)}
</th>
<td>{render(field, feature, key, addUnits(label, value))}</td>
</tr>
))}
</tbody>
<tbody>
{schema.keysTodo.map((key) => (
<tr key={key}>
<th>{key}</th>
<td>{renderValue(key, feature.tags[key])}</td>
</tr>
))}
</tbody>
</Table>
</>
);
};
33 changes: 2 additions & 31 deletions src/components/FeaturePanel/ImageSection/ImageSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 (
<FeatureImage feature={feature} ico={properties.class}>
<PoiType>
<Maki ico={properties.class} invert middle />
<span>{poiType}</span>
</PoiType>
<PoiDescription />

{SHOW_PROTOTYPE_UI && (
<>
Expand Down
46 changes: 46 additions & 0 deletions src/components/FeaturePanel/ImageSection/PoiDescription.tsx
Original file line number Diff line number Diff line change
@@ -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>
<Maki ico={properties.class} invert middle />
<span>{poiType}</span>
</PoiType>
);
};
3 changes: 3 additions & 0 deletions src/components/utils/FeatureContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions src/helpers/featureLabel.ts
Original file line number Diff line number Diff line change
@@ -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}` : '';
Expand Down
14 changes: 14 additions & 0 deletions src/services/__tests__/osmApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

1 comment on commit b3c206b

@vercel
Copy link

@vercel vercel bot commented on b3c206b Jun 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

osmapp – ./

osmapp-git-master-zbycz.vercel.app
osmapp-zbycz.vercel.app
osmapp.vercel.app
osmapp.org

Please sign in to comment.