Skip to content

Commit ac4810a

Browse files
poc(map): Add map to visualize existing address
1 parent 77def9b commit ac4810a

9 files changed

Lines changed: 523 additions & 13 deletions

File tree

assets/controllers/draw_map_controller.js

Lines changed: 34 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { OsrmRouter } from '../customElements/osrm-router';
77
import '../styles/components/draw-map.css';
88

99
export default class extends Controller {
10-
static targets = ['container', 'polygonBtn', 'roadLineBtn', 'validateBtn', 'geometryField'];
10+
static targets = ['container', 'polygonBtn', 'lineBtn', 'roadLineBtn', 'validateBtn', 'geometryField'];
1111
static values = {
1212
centerJson: String,
1313
zoom: Number,
@@ -104,6 +104,7 @@ export default class extends Controller {
104104

105105
updateButtonClasses() {
106106
this.polygonBtnTarget?.classList.toggle('active', this.#currentMode === 'polygon');
107+
this.lineBtnTarget?.classList.toggle('active', this.#currentMode === 'line');
107108
this.roadLineBtnTarget?.classList.toggle('active', this.#currentMode === 'road-line');
108109
}
109110

@@ -112,14 +113,20 @@ export default class extends Controller {
112113
this.updateButtonClasses();
113114
}
114115

116+
toggleLine() {
117+
this.#currentMode = this.#currentMode === 'line' ? null : 'line';
118+
this.updateButtonClasses();
119+
}
120+
115121
toggleRoadLine() {
116122
this.#currentMode = this.#currentMode === 'road-line' ? null : 'road-line';
117123
this.updateButtonClasses();
118124
}
119125

120126
setupDrawing() {
121-
let isDrawingPolygon = false;
122-
let isDrawingRoadLine = false;
127+
this._isDrawingPolygon = false;
128+
this._isDrawingLine = false;
129+
this._isDrawingRoadLine = false;
123130
this._isRoutingInProgress = false;
124131
this._routingPromise = Promise.resolve();
125132

@@ -129,9 +136,18 @@ export default class extends Controller {
129136
}
130137

131138
if (this.#currentMode === 'polygon') {
132-
if (!isDrawingPolygon) {
139+
if (!this._isDrawingPolygon) {
133140
this.#draw.setMode('polygon');
134-
isDrawingPolygon = true;
141+
this._isDrawingPolygon = true;
142+
this.#map.dragPan.disable();
143+
}
144+
this.#draw.addCoordinate(e.lngLat);
145+
this.#draw.updatePreview();
146+
this.#map.getCanvas().style.cursor = 'crosshair';
147+
} else if (this.#currentMode === 'line') {
148+
if (!this._isDrawingLine) {
149+
this.#draw.setMode('line');
150+
this._isDrawingLine = true;
135151
this.#map.dragPan.disable();
136152
}
137153
this.#draw.addCoordinate(e.lngLat);
@@ -140,9 +156,9 @@ export default class extends Controller {
140156
} else if (this.#currentMode === 'road-line') {
141157
if (this._isRoutingInProgress) return;
142158

143-
if (!isDrawingRoadLine) {
159+
if (!this._isDrawingRoadLine) {
144160
this.#draw.setMode('road-line');
145-
isDrawingRoadLine = true;
161+
this._isDrawingRoadLine = true;
146162
}
147163

148164
const newPoint = [e.lngLat.lng, e.lngLat.lat];
@@ -179,12 +195,16 @@ export default class extends Controller {
179195

180196
document.addEventListener('keydown', (e) => {
181197
if (e.key === 'Escape') {
182-
if (isDrawingPolygon) {
183-
isDrawingPolygon = false;
198+
if (this._isDrawingPolygon) {
199+
this._isDrawingPolygon = false;
200+
this.clearPolygonPreview();
201+
}
202+
if (this._isDrawingLine) {
203+
this._isDrawingLine = false;
184204
this.clearPolygonPreview();
185205
}
186-
if (isDrawingRoadLine) {
187-
isDrawingRoadLine = false;
206+
if (this._isDrawingRoadLine) {
207+
this._isDrawingRoadLine = false;
188208
this.#draw.waypoints = [];
189209
this.#draw.routedSegments = [];
190210
this.#draw.clearPreview();
@@ -215,6 +235,9 @@ export default class extends Controller {
215235
if (this.#draw) {
216236
this.#draw.deleteAll();
217237
this.clearPolygonPreview();
238+
this._isDrawingPolygon = false;
239+
this._isDrawingLine = false;
240+
this._isDrawingRoadLine = false;
218241
this.#currentMode = null;
219242
this.updateButtonClasses();
220243
}

assets/controllers/hidden_select_controller.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export default class extends Controller {
1515
this.selectTarget.addEventListener('change', () => {
1616
const selectedOption = this.selectTarget.options[this.selectTarget.selectedIndex];
1717
this.hiddenTarget.value = selectedOption.dataset['value'];
18+
this.hiddenTarget.dispatchEvent(new Event('change', { bubbles: true }));
1819
});
1920
}
2021
}
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
import { Controller } from '@hotwired/stimulus';
2+
import maplibregl from 'maplibre-gl';
3+
import 'maplibre-gl/dist/maplibre-gl.css';
4+
import { mapStyles } from 'carte-facile';
5+
6+
export default class extends Controller {
7+
static targets = ['container'];
8+
static values = {
9+
url: String,
10+
roadType: String,
11+
roadBanIdField: String,
12+
roadNameField: String,
13+
cityCodeField: String,
14+
fromHouseNumberField: String,
15+
fromRoadBanIdField: String,
16+
toHouseNumberField: String,
17+
toRoadBanIdField: String,
18+
directionField: String,
19+
administratorField: String,
20+
roadNumberField: String,
21+
geometryField: String,
22+
strokeColor: { type: String, default: '#000091' },
23+
strokeWidth: { type: Number, default: 3 },
24+
fillColor: { type: String, default: '#000091' },
25+
fillOpacity: { type: Number, default: 0.15 },
26+
};
27+
28+
#map = null;
29+
#abortController = null;
30+
#debounceTimer = null;
31+
#boundDebouncedLoad = null;
32+
33+
connect() {
34+
this.#boundDebouncedLoad = () => this.#debouncedLoad();
35+
this.#observeFieldChanges();
36+
this.#tryLoadGeometry();
37+
}
38+
39+
disconnect() {
40+
this.#abortController?.abort();
41+
clearTimeout(this.#debounceTimer);
42+
this.#stopListeningForm();
43+
this.#map?.remove();
44+
this.#map = null;
45+
}
46+
47+
#observeFieldChanges() {
48+
// Listen to events bubbling up from any form field within the parent location card
49+
const locationCard = this.element.closest('[data-controller~="form-reveal"]')
50+
|| this.element.closest('[data-controller~="reset"]')
51+
|| this.element.parentElement;
52+
53+
if (locationCard) {
54+
locationCard.addEventListener('input', this.#boundDebouncedLoad);
55+
locationCard.addEventListener('change', this.#boundDebouncedLoad);
56+
locationCard.addEventListener('autocomplete.change', this.#boundDebouncedLoad);
57+
this._locationCard = locationCard;
58+
}
59+
}
60+
61+
#stopListeningForm() {
62+
if (this._locationCard && this.#boundDebouncedLoad) {
63+
this._locationCard.removeEventListener('input', this.#boundDebouncedLoad);
64+
this._locationCard.removeEventListener('change', this.#boundDebouncedLoad);
65+
this._locationCard.removeEventListener('autocomplete.change', this.#boundDebouncedLoad);
66+
this._locationCard = null;
67+
}
68+
}
69+
70+
#debouncedLoad() {
71+
clearTimeout(this.#debounceTimer);
72+
this.#debounceTimer = setTimeout(() => this.#tryLoadGeometry(), 500);
73+
}
74+
75+
#tryLoadGeometry() {
76+
const roadType = this.roadTypeValue;
77+
78+
if (roadType === 'lane') {
79+
this.#loadForNamedStreet();
80+
} else if (roadType === 'departmentalRoad' || roadType === 'nationalRoad') {
81+
this.#loadForNumberedRoad();
82+
} else if (roadType === 'rawGeoJSON') {
83+
this.#loadForRawGeoJSON();
84+
}
85+
}
86+
87+
#loadForNamedStreet() {
88+
const field = document.getElementById(this.roadBanIdFieldValue);
89+
const roadBanId = field?.value;
90+
91+
if (!roadBanId) {
92+
this.#hideMap();
93+
return;
94+
}
95+
96+
const params = new URLSearchParams({ roadType: 'lane', roadBanId });
97+
98+
const roadNameField = document.getElementById(this.roadNameFieldValue);
99+
const cityCodeField = document.getElementById(this.cityCodeFieldValue);
100+
const fromHouseNumberField = document.getElementById(this.fromHouseNumberFieldValue);
101+
const fromRoadBanIdField = document.getElementById(this.fromRoadBanIdFieldValue);
102+
const toHouseNumberField = document.getElementById(this.toHouseNumberFieldValue);
103+
const toRoadBanIdField = document.getElementById(this.toRoadBanIdFieldValue);
104+
const directionField = document.getElementById(this.directionFieldValue);
105+
106+
if (roadNameField?.value) params.set('roadName', roadNameField.value);
107+
if (cityCodeField?.value) params.set('cityCode', cityCodeField.value);
108+
if (fromHouseNumberField?.value) params.set('fromHouseNumber', fromHouseNumberField.value);
109+
if (fromRoadBanIdField?.value) params.set('fromRoadBanId', fromRoadBanIdField.value);
110+
if (toHouseNumberField?.value) params.set('toHouseNumber', toHouseNumberField.value);
111+
if (toRoadBanIdField?.value) params.set('toRoadBanId', toRoadBanIdField.value);
112+
if (directionField?.value) params.set('direction', directionField.value);
113+
114+
this.#fetchAndDisplay(params);
115+
}
116+
117+
#loadForNumberedRoad() {
118+
const administratorField = document.getElementById(this.administratorFieldValue);
119+
const roadNumberField = document.getElementById(this.roadNumberFieldValue);
120+
const administrator = administratorField?.value;
121+
const roadNumber = roadNumberField?.value;
122+
123+
if (!administrator || !roadNumber) {
124+
this.#hideMap();
125+
return;
126+
}
127+
128+
const params = new URLSearchParams({
129+
roadType: this.roadTypeValue,
130+
administrator,
131+
roadNumber,
132+
});
133+
this.#fetchAndDisplay(params);
134+
}
135+
136+
#loadForRawGeoJSON() {
137+
const field = document.getElementById(this.geometryFieldValue);
138+
const raw = field?.value;
139+
140+
if (!raw) {
141+
this.#hideMap();
142+
return;
143+
}
144+
145+
try {
146+
const geojson = JSON.parse(raw);
147+
this.#displayGeometry(geojson);
148+
} catch {
149+
this.#hideMap();
150+
}
151+
}
152+
153+
async #fetchAndDisplay(params) {
154+
this.#abortController?.abort();
155+
this.#abortController = new AbortController();
156+
157+
const url = `${this.urlValue}?${params.toString()}`;
158+
159+
try {
160+
const response = await fetch(url, { signal: this.#abortController.signal });
161+
162+
if (response.status === 204 || !response.ok) {
163+
this.#hideMap();
164+
return;
165+
}
166+
167+
const geojson = await response.json();
168+
this.#displayGeometry(geojson);
169+
} catch (error) {
170+
if (error.name !== 'AbortError') {
171+
this.#hideMap();
172+
}
173+
}
174+
}
175+
176+
#displayGeometry(geojson) {
177+
if (!geojson || !geojson.coordinates || geojson.coordinates.length === 0) {
178+
this.#hideMap();
179+
return;
180+
}
181+
182+
this.containerTarget.hidden = false;
183+
184+
if (this.#map) {
185+
this.#updateMapData(geojson);
186+
} else {
187+
this.#initializeMap(geojson);
188+
}
189+
}
190+
191+
#initializeMap(geojson) {
192+
this.#map = new maplibregl.Map({
193+
container: this.containerTarget,
194+
style: mapStyles.desaturated,
195+
interactive: false,
196+
attributionControl: false,
197+
});
198+
199+
this.#map.on('load', () => {
200+
this.#addSourceAndLayers(geojson);
201+
this.#fitBounds(geojson);
202+
});
203+
204+
this.#map.on('error', () => {
205+
this.#hideMap();
206+
});
207+
}
208+
209+
#addSourceAndLayers(geojson) {
210+
this.#map.addSource('location-preview', {
211+
type: 'geojson',
212+
data: geojson,
213+
});
214+
215+
const geometryType = geojson.type;
216+
const isPolygon = geometryType === 'Polygon' || geometryType === 'MultiPolygon';
217+
218+
if (isPolygon) {
219+
this.#map.addLayer({
220+
id: 'location-preview-fill',
221+
type: 'fill',
222+
source: 'location-preview',
223+
paint: {
224+
'fill-color': this.fillColorValue,
225+
'fill-opacity': this.fillOpacityValue,
226+
},
227+
});
228+
}
229+
230+
this.#map.addLayer({
231+
id: 'location-preview-line',
232+
type: 'line',
233+
source: 'location-preview',
234+
paint: {
235+
'line-color': this.strokeColorValue,
236+
'line-width': this.strokeWidthValue,
237+
},
238+
});
239+
}
240+
241+
#updateMapData(geojson) {
242+
const source = this.#map.getSource('location-preview');
243+
244+
if (source) {
245+
source.setData(geojson);
246+
} else if (this.#map.loaded()) {
247+
this.#addSourceAndLayers(geojson);
248+
}
249+
250+
this.#fitBounds(geojson);
251+
}
252+
253+
#fitBounds(geojson) {
254+
const bounds = new maplibregl.LngLatBounds();
255+
this.#processCoordinates(geojson.coordinates, bounds);
256+
257+
if (!bounds.isEmpty()) {
258+
this.#map.fitBounds(bounds, {
259+
padding: 40,
260+
maxZoom: 15,
261+
animate: false,
262+
});
263+
}
264+
}
265+
266+
#processCoordinates(coordinates, bounds) {
267+
if (!Array.isArray(coordinates) || coordinates.length === 0) {
268+
return;
269+
}
270+
271+
if (typeof coordinates[0] === 'number' && typeof coordinates[1] === 'number') {
272+
bounds.extend([coordinates[0], coordinates[1]]);
273+
return;
274+
}
275+
276+
coordinates.forEach(coord => {
277+
if (Array.isArray(coord)) {
278+
this.#processCoordinates(coord, bounds);
279+
}
280+
});
281+
}
282+
283+
#hideMap() {
284+
this.containerTarget.hidden = true;
285+
}
286+
}

0 commit comments

Comments
 (0)