From 3553abed15293961fdbc825858d0d984d4fca1a2 Mon Sep 17 00:00:00 2001 From: Kyle Hensel Date: Sun, 16 Apr 2023 19:42:29 +1200 Subject: [PATCH] render diameter when a feature is selected --- CHANGELOG.md | 2 + css/20_map.css | 19 +++++++ modules/core/index.ts | 1 + modules/core/planar.ts | 113 +++++++++++++++++++++++++++++++++++++++ modules/renderer/map.js | 1 + modules/svg/points.js | 23 ++++++++ modules/svg/vertices.js | 15 ++++++ test/spec/core/planar.ts | 74 +++++++++++++++++++++++++ 8 files changed, 248 insertions(+) create mode 100644 modules/core/planar.ts create mode 100644 test/spec/core/planar.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 427d31b2753..72c84325f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ _Breaking developer changes, which may affect downstream projects or sites that * Make tags like `contact:instagram` clickable if they contain a plain username, or a full URL ([#12306], thanks [@k-yle]) * Show suggestions in combobox dropdown also when the entered text contains a few typos ([#8802]) * Render the `side` arrow of cyclist waiting aid features ([#12374], thanks [@RudyTheDev]) +* Render diameter and radius tags when a node is selected ([#9732], thanks [@k-yle]) #### :scissors: Operations #### :camera: Street-Level * Add high-resolution toggle for Mapilio photo viewer ([#12353], thanks [@sezerbozbiyik]) @@ -69,6 +70,7 @@ _Breaking developer changes, which may affect downstream projects or sites that [#8802]: https://github.com/openstreetmap/iD/issues/8802 [#8848]: https://github.com/openstreetmap/iD/issues/8848 +[#9732]: https://github.com/openstreetmap/iD/pull/9732 [#10615]: https://github.com/openstreetmap/iD/pull/10615 [#11634]: https://github.com/openstreetmap/iD/pull/11634 [#12297]: https://github.com/openstreetmap/iD/issues/12297 diff --git a/css/20_map.css b/css/20_map.css index 432c8421d8b..b552b3a57f9 100644 --- a/css/20_map.css +++ b/css/20_map.css @@ -188,6 +188,25 @@ g.vertex.selected .shadow { fill-opacity: 0.7; } +/* circle to visualize radius/diameter */ +g.point .radius, +g.vertex .radius { + fill: #333; + fill-opacity: 0.6; + stroke: #fff; + stroke-width: 0.5px; + stroke-opacity: 0.75; +} + +.fill-wireframe g.point .radius, +.fill-wireframe g.vertex .radius { + fill: none; +} +.surface.tr g.point .radius, +.surface.tr g.vertex .radius { + display: none; +} + /* lines */ .preset-icon .icon.iD-other-line { diff --git a/modules/core/index.ts b/modules/core/index.ts index c68d4292207..f3e2e87cff7 100644 --- a/modules/core/index.ts +++ b/modules/core/index.ts @@ -5,6 +5,7 @@ export { coreGraph } from './graph'; export { coreHistory } from './history'; export { coreLocalizer, t, localizer } from './localizer'; export { locationManager } from './location_manager'; +export * from './planar'; export { prefs } from './preferences'; export { coreTree } from './tree'; export { coreUploader } from './uploader'; diff --git a/modules/core/planar.ts b/modules/core/planar.ts new file mode 100644 index 00000000000..fc4dd0804c1 --- /dev/null +++ b/modules/core/planar.ts @@ -0,0 +1,113 @@ +import type { GeoProjection } from 'd3'; +import { geoMetersToLat } from '../geo'; + +/** + * Parses a distance value from an OSM tag, which + * could either be a number, or a number with a unit + * (e.g. `15 m`). + * @returns value in metres or `undefined` if the value is invalid + */ +export function parseDistanceWithUnit(tagValue: string, defaultUnit: string) { + if (!tagValue) return undefined; // tag doesn't exist + + // special case when feet and inches are used together. + const imperialCombo = tagValue.match( + /([\d.]+) *('|ft|foot|feet) *([\d.]+) *("|in|inch|inches)/, + ); + if (imperialCombo) { + const feet = +imperialCombo[1]; + const inches = +imperialCombo[3]; + return feet / 3.281 + inches / 39.37; + } + + // the remaining code parses normal values (number + optional unit) + + const unit = tagValue.match(/[^\d.]+/)?.[0].trim() || defaultUnit; + const value = parseFloat(tagValue); + + if (Number.isNaN(value) || !Number.isFinite(value)) return undefined; // invalid value + + // convert to metres + switch (unit) { + case 'mm': + return value / 1e3; + case 'cm': + return value / 1e2; + case 'metres': + case 'm': + return value; + case 'hectometres': + case 'hm': + return value * 1e2; + case 'kilometres': + case 'km': + return value * 1e3; + + case 'statute_miles': + case 'miles': + case 'mi': + return value * 1609; + + case 'nm': + return value * 1852; + + case 'yard': + case 'yards': + case 'yd': + return value / 1.094; + + case '\'': + case 'feet': + case 'foot': + case 'ft': + return value / 3.281; + + case '"': + case 'inch': + case 'inches': + case 'in': + return value / 39.37; + + default: + return undefined; + } +} + +export function getRadiusTag(tags: Tags) { + const diameter = + parseDistanceWithUnit(tags.diameter, 'mm') || + parseDistanceWithUnit(tags.diameter_crown, 'm') || + parseDistanceWithUnit(tags['hole:diameter'], 'm'); + if (diameter) return diameter / 2; + + const radius = + parseDistanceWithUnit(tags.radius, 'm') || + parseDistanceWithUnit(tags.crown_radius, 'm') || + parseDistanceWithUnit( + tags['seamark:anchor_berth:radius'], + tags['seamark:anchor_berth:units'] || 'm', + ); + if (radius) return radius; + + return undefined; +} + +export function getRadiusInPixels(node: iD.OsmNode, projection: GeoProjection) { + const radius = getRadiusTag(node.tags); + if (!radius) return 0; + + const center = projection(node.loc)!; + const pointOnCircumference = projection([ + node.loc[0], + node.loc[1] + geoMetersToLat(radius), + ])!; + + // The radius is the difference in latitude between + // the centre and the point on the circumference. + const pixels = center[1] - pointOnCircumference[1]; + + // don't try to render a circle that's too big to fit on the screen + if (pixels > window.innerHeight || pixels > window.innerWidth) return 0; + + return Math.max(0, pixels); +} diff --git a/modules/renderer/map.js b/modules/renderer/map.js index b8f2e9acee1..26a3ea57bb2 100644 --- a/modules/renderer/map.js +++ b/modules/renderer/map.js @@ -376,6 +376,7 @@ export function rendererMap(context) { if (mode && mode.id === 'select') { // update selected vertices - the user might have just double-clicked a way, // creating a new vertex, triggering a partial redraw without a mode change + surface.call(drawPoints.drawSelected, graph, map.extent()); surface.call(drawVertices.drawSelected, graph, map.extent()); } diff --git a/modules/svg/points.js b/modules/svg/points.js index 16d48391f45..1c191a3b7b6 100644 --- a/modules/svg/points.js +++ b/modules/svg/points.js @@ -7,6 +7,7 @@ import { osmEntity } from '../osm'; import { svgPointTransform } from './helpers'; import { svgTagClasses } from './tag_classes'; import { presetManager } from '../presets'; +import { getRadiusInPixels, getRadiusTag } from '../core'; import { textWidth, isAddressPoint } from './labels'; export function svgPoints(projection, context) { @@ -87,6 +88,7 @@ export function svgPoints(projection, context) { function drawPoints(selection, graph, entities, filter) { + const selected = context.selectedIDs(); var wireframe = context.surface().classed('fill-wireframe'); var zoom = geoScaleToZoom(projection.scale()); var base = context.history().base(); @@ -122,6 +124,20 @@ export function svgPoints(projection, context) { .append('path') .call(markerPath, 'shadow'); + // Highlighted vertices with a radius/diameter get a circle + const circles = groups + .selectAll('.radius') + .data(d => selected.includes(d.id) && getRadiusTag(d.tags) ? [d] : [], fastEntityKey); + + circles.exit().remove(); + circles.enter() + .append('circle') + .attr('class', 'radius') + .merge(circles) + .attr('r', node => + Math.max(0, getRadiusInPixels(node, context.projection)) + ); + enter.each(function(d) { if (isAddressPoint(d.tags)) return; d3_select(this) @@ -173,6 +189,13 @@ export function svgPoints(projection, context) { .call(drawTargets, graph, points, filter); } + drawPoints.drawSelected = (selection, graph, extent) => { + const selectedIds = context.selectedIDs(); + const filter = (d) => selectedIds.includes(d.id); + const seletcedEntities = selectedIds.map(id => graph.hasEntity(id)).filter(Boolean); + drawPoints(selection, graph, seletcedEntities, filter, extent, false); + }; + return drawPoints; } diff --git a/modules/svg/vertices.js b/modules/svg/vertices.js index 158e9010ab1..056fe286573 100644 --- a/modules/svg/vertices.js +++ b/modules/svg/vertices.js @@ -5,6 +5,7 @@ import { presetManager } from '../presets'; import { geoScaleToZoom } from '../geo'; import { osmEntity } from '../osm'; import { svgPassiveVertex, svgPointTransform } from './helpers'; +import { getRadiusInPixels, getRadiusTag } from '../core'; import { svgTagClasses } from './tag_classes'; export function svgVertices(projection, context) { @@ -165,6 +166,20 @@ export function svgVertices(projection, context) { return picon ? '#' + picon : ''; }); + // Highlighted vertices with a radius/diameter get a circle + const circles = groups + .selectAll('.radius') + .data(d => context.selectedIDs().includes(d.id) && getRadiusTag(d.tags) ? [d] : [], fastEntityKey); + + circles.exit().remove(); + circles.enter() + .append('circle') + .attr('class', 'radius') + .merge(circles) + .attr('r', node => + Math.max(0, getRadiusInPixels(node, context.projection)) + ); + // Vertices with directions get viewfields var dgroups = groups diff --git a/test/spec/core/planar.ts b/test/spec/core/planar.ts new file mode 100644 index 00000000000..3fd03da7586 --- /dev/null +++ b/test/spec/core/planar.ts @@ -0,0 +1,74 @@ +import type { GeoProjection } from 'd3'; +import type { Vec2 } from '../../../modules/geo/vector'; + +describe(iD.parseDistanceWithUnit, () => { + it('handles valid tag values', () => { + expect(iD.parseDistanceWithUnit(' 12.5 ', 'm')).toBe(12.5); + expect(iD.parseDistanceWithUnit('12cm', 'm')).toBe(0.12); + expect(iD.parseDistanceWithUnit('12 cm', 'm')).toBe(0.12); + expect(iD.parseDistanceWithUnit('12000', 'mm')).toBe(12); + expect(iD.parseDistanceWithUnit('5feet', 'm')).toBe(1.5239256324291375); + expect(iD.parseDistanceWithUnit('5ft', 'm')).toBe(1.5239256324291375); + expect(iD.parseDistanceWithUnit('5\'', 'm')).toBe(1.5239256324291375); + expect(iD.parseDistanceWithUnit('5\'9"', 'm')).toBe(1.7525260896300519); + expect(iD.parseDistanceWithUnit('5foot 9"', 'm')).toBe( + 1.7525260896300519, + ); + expect(iD.parseDistanceWithUnit(' 5 feet 9 inches ', 'm')).toBe( + 1.7525260896300519, + ); + expect(iD.parseDistanceWithUnit(' 5.5\' 9.12" ', 'm')).toBe( + 1.9079666589689779, + ); + }); + + it('handles invalid tag values', () => { + expect(iD.parseDistanceWithUnit('', 'm')).toBeUndefined(); + expect(iD.parseDistanceWithUnit('15 bananas', 'm')).toBeUndefined(); + expect(iD.parseDistanceWithUnit('qwertyuiop', 'm')).toBeUndefined(); + }); +}); + +describe(iD.getRadiusTag, () => { + it('can identify the radius tag', () => { + expect(iD.getRadiusTag({ radius: '10' })).toBe(10); + expect(iD.getRadiusTag({ 'hole:diameter': '10' })).toBe(5); + expect(iD.getRadiusTag({ 'hole:diameter': '6 ft' })).toBe( + 0.9143553794574825, + ); + expect(iD.getRadiusTag({ 'seamark:anchor_berth:radius': '0.2' })).toBe( + 0.2, + ); + expect( + iD.getRadiusTag({ + 'seamark:anchor_berth:radius': '0.2', + 'seamark:anchor_berth:units': 'nm', + }), + ).toBe(370.40000000000003); + + expect(iD.getRadiusTag({})).toBeUndefined(); + }); +}); + +describe(iD.getRadiusInPixels, () => { + it('sanity check that the test environment has screen dimensions', () => { + expect(window.innerWidth).toBe(1024); + expect(window.innerHeight).toBe(768); + }); + it('can convert metres to pixels', () => { + // fake coordinate system + const projection = ( + (([lng, lat]) => [lng / 4 - 5, lat / 2 - 3]) + ); + projection.invert = ([x, y]: Vec2) => [(x + 5) * 4, (y + 3) * 2]; + + const node = iD.osmNode({ + id: 'a', + loc: [174.7822771, -36.809856], + tags: { highway: 'turning_circle', diameter: '16m' }, + }); + + const px = 0.0002404180484063545; + expect(iD.getRadiusInPixels(node, d3.geoMercator())).toBe(px); + }); +});