1// js/map-view/drawing/vertex-entry-panel.js 2// 3// Drawing a line or an area without a pointer. 4// 5// The claim that freehand drawing is inherently exempt from WCAG 2.1.1 6// does not survive contact with what the drawing tool actually does: it 7// collects an ordered list of coordinates and closes it on a double 8// click. Nothing about that requires pointer movement. So this collects 9// the same list from the same location chooser the point flow uses, one 10// vertex at a time, with the shape previewed on the map as it grows and 11// a Finish that is a button rather than a double click. 12// 13// It runs alongside Mapbox Draw rather than replacing it. Whoever 14// finishes first â the mouse through Draw, or this panel â creates the 15// draft, and the other route is torn down. That is the same "both armed" 16// arrangement the point flow uses, and it means the mouse experience is 17// byte for byte what it was. 18 19import { createContributionPanel } from './contribution-panel.js'; 20import { createLocationInput } from './location-input.js'; 21import { ensurePreview, updatePreview, clearPreview } from './vertex-preview.js'; 22 23/** 24 * @param {object} opts 25 * @param {object} opts.cat 26 * @param {mapboxgl.Map} opts.map 27 * @param {'linestring'|'polygon'} opts.kind 28 * @param {Function} opts.onFinish async (geometry) => void 29 * @param {Function} opts.onCancel 30 */ 31export function openVertexEntry({ cat, map, kind, onFinish, onCancel }) { 32 const isPolygon = kind === 'polygon'; 33 const shape = isPolygon ? 'area' : 'line'; 34 const minVertices = isPolygon ? 3 : 2; 35 const catName = cat?.name ? `: ${cat.name}` : ''; 36 37 /** @type {Array<[number, number]>} */ 38 const vertices = []; 39 40 const panel = createContributionPanel({ 41 title: `Draw ${isPolygon ? 'an area' : 'a line'}${catName}`, 42 intro: isPolygon 43 ? 'Click the map to draw, or add corners one at a time below. An area needs at least three corners; the last corner joins back to the first automatically.' 44 : 'Click the map to draw, or add points one at a time below. A line needs at least two points.', 45 onCancel: () => { clearPreview(map); onCancel(); } 46 }); 47 48 ensurePreview(map, cat?.stroke_color || cat?.color || '#0d6efd'); 49 50 const location = createLocationInput({ 51 map, 52 legend: `Add a ${isPolygon ? 'corner' : 'point'} to this ${shape}`, 53 onStatus: panel.setStatus 54 }); 55 // Entering the radio group carries the panel's instructions with it, 56 // which is where a reader in focus mode actually needs them. 57 location.el.setAttribute('aria-describedby', panel.introId); 58 panel.body.appendChild(location.el); 59 60 const addBtn = document.createElement('button'); 61 addBtn.type = 'button'; 62 addBtn.className = 'btn btn-sm btn-outline-primary'; 63 addBtn.textContent = isPolygon ? 'Add corner' : 'Add point'; 64 panel.body.appendChild(addBtn); 65 66 const listWrap = document.createElement('div'); 67 listWrap.className = 'wm-vx-list-wrap'; 68 listWrap.innerHTML = ` 69 <h3 class="wm-vx-heading"></h3> 70 <ol class="wm-vx-list"></ol>`; 71 panel.body.appendChild(listWrap); 72 const heading = listWrap.querySelector('.wm-vx-heading'); 73 const list = listWrap.querySelector('.wm-vx-list'); 74 75 const finish = document.createElement('button'); 76 finish.type = 'button'; 77 finish.className = 'btn btn-sm btn-primary'; 78 finish.textContent = isPolygon ? 'Finish area' : 'Finish line'; 79 finish.disabled = true; 80 panel.actions.appendChild(finish); 81 82 addBtn.addEventListener('click', () => { 83 const got = location.resolve(); 84 if (!got.ok) { panel.setStatus(got.error); return; } 85 vertices.push(got.lngLat); 86 location.reset(); 87 render(); 88 panel.setStatus( 89 `Added ${got.label}. This ${shape} now has ${vertices.length} of the ${minVertices} ${ 90 isPolygon ? 'corners' : 'points'} it needs.` 91 ); 92 location.focusFirst(); 93 }); 94 95 list.addEventListener('click', (e) => { 96 const btn = e.target.closest('button[data-act]'); 97 if (!btn) return; 98 const i = Number(btn.dataset.index); 99 if (!Number.isInteger(i) || !vertices[i]) return; 100 101 if (btn.dataset.act === 'remove') { 102 vertices.splice(i, 1); 103 render(); 104 panel.setStatus(`Removed. ${vertices.length} left.`); 105 focusRow(Math.min(i, vertices.length - 1)); 106 return; 107 } 108 const to = btn.dataset.act === 'up' ? i - 1 : i + 1; 109 if (to < 0 || to >= vertices.length) return; 110 [vertices[i], vertices[to]] = [vertices[to], vertices[i]]; 111 render(); 112 panel.setStatus(`Moved to position ${to + 1} of ${vertices.length}.`); 113 focusRow(to, btn.dataset.act); 114 }); 115 116 finish.addEventListener('click', async () => { 117 if (vertices.length < minVertices) { 118 panel.setStatus(`A ${shape} needs at least ${minVertices} ${isPolygon ? 'corners' : 'points'}.`); 119 return; 120 } 121 finish.disabled = true; 122 panel.setStatus(`Saving this ${shape}â¦`); 123 const geometry = isPolygon 124 ? { type: 'Polygon', coordinates: [[...vertices, vertices[0]]] } 125 : { type: 'LineString', coordinates: [...vertices] }; 126 try { 127 location.destroy(); 128 clearPreview(map); 129 panel.close({ silent: true, restoreFocus: false }); 130 await onFinish(geometry); 131 } catch (err) { 132 console.error('[vertex-entry] failed:', err); 133 } 134 }); 135 136 /** Redraw the vertex list and the map preview. */ 137 function render() { 138 heading.textContent = vertices.length 139 ? `${isPolygon ? 'Corners' : 'Points'} in this ${shape} (${vertices.length})` 140 : `No ${isPolygon ? 'corners' : 'points'} yet`; 141 142 list.replaceChildren(); 143 const noun = isPolygon ? 'corner' : 'point';
144 vertices.forEach(([lng, lat], i) => { 145 const li = document.createElement('li'); 146 li.className = 'wm-vx-item'; 147 const coords = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; 148 // The position is written into the row rather than left to the <ol> 149 // marker. Order is the whole meaning of this list â swap two rows 150 // and the line goes somewhere else â so it has to be in the text a 151 // reader hears, and the flex layout was suppressing the marker 152 // anyway. 153 const label = `${noun} ${i + 1}, ${coords}`; 154 li.innerHTML = ` 155 <span class="wm-vx-coords"><span class="wm-vx-index">${i + 1}.</span> ${coords}</span> 156 <span class="wm-vx-actions"> 157 <button type="button" class="btn btn-sm btn-link" data-act="up" data-index="${i}" 158 ${i === 0 ? 'disabled' : ''} aria-label="Move ${label} earlier">Up</button> 159 <button type="button" class="btn btn-sm btn-link" data-act="down" data-index="${i}" 160 ${i === vertices.length - 1 ? 'disabled' : ''} aria-label="Move ${label} later">Down</button> 161 <button type="button" class="btn btn-sm btn-link text-danger" data-act="remove" data-index="${i}" 162 aria-label="Remove ${label}">Remove</button> 163 </span>`; 164 list.appendChild(li); 165 }); 166 167 finish.disabled = vertices.length < minVertices; 168 updatePreview(map, vertices, kind); 169 } 170 171 /** 172 * Put focus back on the row the reader was working with. Rebuilding 173 * the list destroys the button they pressed, and losing focus to 174 * <body> after every Up press makes the control unusable. 175 */ 176 function focusRow(index, act) { 177 if (index < 0) { addBtn.focus(); return; } 178 const row = list.children[index]; 179 const target = row?.querySelector(`button[data-act="${act || 'remove'}"]:not([disabled])`) 180 || row?.querySelector('button[data-act]:not([disabled])'); 181 target?.focus(); 182 } 183 184 render(); 185 setTimeout(() => location.focusFirst(), 120); 186 187 return { 188 close: (opts) => { location.destroy(); clearPreview(map); panel.close(opts); }, 189 isOpen: panel.isOpen 190 }; 191}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.