PageSourceSearch

https://bikeatthebeach.com/js/map-view/drawing/vertex-entry-panel.js

js bikeatthebeach.com collected 2026-09-24 13:59:10 UTC 7,705 bytes, 191 lines download raw bytes

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.