• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

code4recovery / tsml-ui / 14920598081

09 May 2025 03:12AM UTC coverage: 60.536% (-0.5%) from 61.057%
14920598081

Pull #447

github

web-flow
Merge 1f46fce2b into 9519f3f31
Pull Request #447: Feat: Add support for OSM map if no mapbox key is provided

530 of 968 branches covered (54.75%)

Branch coverage included in aggregate %.

4 of 10 new or added lines in 2 files covered. (40.0%)

108 existing lines in 7 files now uncovered.

668 of 1011 relevant lines covered (66.07%)

9.39 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

69.42
/src/components/Map.tsx
1
import { Dispatch, SetStateAction, useEffect, useRef, useState } from 'react';
2

3
import ReactMapGL, { Marker, NavigationControl, Popup } from 'react-map-gl';
4
import WebMercatorViewport from 'viewport-mercator-project';
5

6
import { formatDirectionsUrl, useSettings } from '../helpers';
7
import { mapCss, mapPopupCss, mapPopupMeetingsCss } from '../styles';
8

9
import Button from './Button';
10
import Link from './Link';
11

12
import type { Meeting, State } from '../types';
13

14
import {
15
  MapContainer,
16
  TileLayer,
17
  Marker as LeafletMarker,
18
  Popup as LeafletPopup,
19
} from 'react-leaflet';
20
import L from 'leaflet';
21
import 'leaflet/dist/leaflet.css';
22

23
// Override default icon paths for Leaflet markers using CDN URLs
24
L.Icon.Default.mergeOptions({
2✔
25
  iconRetinaUrl:
26
    'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
27
  iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
28
  shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
29
});
30

31
type Locations = {
32
  [index: string]: {
33
    directions_url: string;
34
    formatted_address: string;
35
    latitude: number;
36
    longitude: number;
37
    meetings: Meeting[];
38
    name?: string;
39
  };
40
};
41

42
type Bounds = {
43
  north?: number;
44
  east?: number;
45
  south?: number;
46
  west?: number;
47
};
48

49
type Viewport = {
50
  width: number;
51
  height: number;
52
  zoom: number;
53
  latitude: number;
54
  longitude: number;
55
};
56

57
export default function Map({
58
  filteredSlugs,
59
  listMeetingsInPopup = true,
×
60
  state,
61
  setState,
62
  mapbox,
63
}: {
64
  filteredSlugs: string[];
65
  listMeetingsInPopup: boolean;
66
  mapbox?: string;
67
  setState: Dispatch<SetStateAction<State>>;
68
  state: State;
69
}) {
70
  const { settings, strings } = useSettings();
22✔
71
  const [popup, setPopup] = useState<string | undefined>();
22✔
72
  const [viewport, setViewport] = useState<Viewport | undefined>();
22✔
73
  const [data, setData] = useState<{
22✔
74
    locations: Locations;
75
    bounds: Bounds;
76
    locationKeys: string[];
77
  }>({
78
    locations: {},
79
    bounds: {},
80
    locationKeys: [],
81
  });
82
  const [dimensions, setDimensions] = useState<{
22✔
83
    width: number;
84
    height: number;
85
  }>();
86
  const mapFrame = useRef<HTMLDivElement>(null);
22✔
87

88
  //window size listener (todo figure out why height can go up but not down)
89
  useEffect(() => {
22✔
90
    const resizeListener = () => {
8✔
91
      if (!mapFrame.current) return;
8!
92
      const { width, height } = mapFrame.current.getBoundingClientRect();
8✔
93
      if (width && height) {
8✔
94
        setDimensions({
3✔
95
          width: width - 2,
96
          height: height - 2,
97
        });
98
      }
99
    };
100
    resizeListener();
8✔
101
    window.addEventListener('resize', resizeListener);
8✔
102
    return () => {
8✔
103
      window.removeEventListener('resize', resizeListener);
8✔
104
    };
105
  }, []);
106

107
  //reset bounds and locations when filteredSlugs changes
108
  useEffect(() => {
22✔
109
    const locations: Locations = {};
10✔
110
    const bounds: Bounds = {};
10✔
111

112
    filteredSlugs.forEach(slug => {
10✔
113
      const meeting = state.meetings[slug];
9✔
114

115
      if (meeting?.latitude && meeting?.longitude && meeting?.isInPerson) {
9✔
116
        const coords = meeting.latitude + ',' + meeting.longitude;
2✔
117

118
        //create a new pin
119
        if (!locations[coords]) {
2!
120
          locations[coords] = {
2✔
121
            directions_url: formatDirectionsUrl(meeting),
122
            formatted_address: meeting.formatted_address,
123
            latitude: meeting.latitude,
124
            longitude: meeting.longitude,
125
            meetings: [],
126
            name: meeting.location,
127
          };
128
        }
129

130
        //expand bounds
131
        if (!bounds.north || meeting.latitude > bounds.north)
2!
132
          bounds.north = meeting.latitude;
2✔
133
        if (!bounds.south || meeting.latitude < bounds.south)
2!
134
          bounds.south = meeting.latitude;
2✔
135
        if (!bounds.east || meeting.longitude > bounds.east)
2!
136
          bounds.east = meeting.longitude;
2✔
137
        if (!bounds.west || meeting.longitude < bounds.west)
2!
138
          bounds.west = meeting.longitude;
2✔
139

140
        //add meeting to pin
141
        locations[coords].meetings.push(meeting);
2✔
142
      }
143
    });
144

145
    //quick reference array
146
    const locationKeys: string[] = Object.keys(locations).sort(
10✔
147
      (a, b) => locations[b].latitude - locations[a].latitude
×
148
    );
149

150
    //set state (sort so southern pins appear in front)
151
    setData({
10✔
152
      bounds: bounds,
153
      locations: locations,
154
      locationKeys: locationKeys,
155
    });
156

157
    //show popup if only one
158
    if (locationKeys.length === 1) {
10✔
159
      setPopup(locationKeys[0]);
2✔
160
    }
161
  }, [filteredSlugs]);
162

163
  //reset viewport when data or dimensions change
164
  useEffect(() => {
22✔
165
    if (
18✔
166
      !dimensions ||
30✔
167
      !data.bounds ||
168
      !data.bounds.north ||
169
      !data.bounds.east ||
170
      !data.bounds.south ||
171
      !data.bounds.west
172
    )
173
      return;
16✔
174
    setViewport(
2✔
175
      data.bounds.west === data.bounds.east
2!
176
        ? {
177
            ...dimensions,
178
            latitude: data.bounds.north,
179
            longitude: data.bounds.west,
180
            zoom: 14,
181
          }
182
        : new WebMercatorViewport(dimensions).fitBounds(
183
            [
184
              [data.bounds.west, data.bounds.south],
185
              [data.bounds.east, data.bounds.north],
186
            ],
187
            {
188
              padding: Math.min(dimensions.width, dimensions.height) / 10,
189
            }
190
          )
191
    );
192
  }, [data, dimensions]);
193

194
  return (
22✔
195
    <div aria-hidden={true} css={mapCss} ref={mapFrame}>
196
      {viewport &&
26✔
197
        !!data.locationKeys.length &&
198
        (mapbox ? (
2!
199
          <ReactMapGL
200
            mapStyle={settings.map.style}
201
            mapboxAccessToken={mapbox}
202
            initialViewState={viewport}
203
            onMove={event => {
NEW
UNCOV
204
              setViewport({
×
205
                ...viewport,
206
                zoom: event.viewState.zoom,
207
                latitude: event.viewState.latitude,
208
                longitude: event.viewState.longitude,
209
              });
210
            }}
211
          >
212
            {data.locationKeys.map(key => (
NEW
UNCOV
213
              <div key={key}>
×
214
                <Marker
215
                  latitude={data.locations[key].latitude}
216
                  longitude={data.locations[key].longitude}
217
                >
218
                  <div
219
                    data-testid={key}
NEW
UNCOV
220
                    onClick={() => setPopup(key)}
×
221
                    style={settings.map.markers.location}
222
                    title={data.locations[key].name}
223
                  />
224
                </Marker>
225
                {popup === key && (
×
226
                  <Popup
227
                    closeOnClick={false}
228
                    focusAfterOpen={false}
229
                    latitude={data.locations[key].latitude}
230
                    longitude={data.locations[key].longitude}
NEW
UNCOV
231
                    onClose={() => setPopup(undefined)}
×
232
                  >
233
                    <div css={mapPopupCss}>
234
                      <h2>{data.locations[key].name}</h2>
235
                      <p className="notranslate">
236
                        {data.locations[key].formatted_address}
237
                      </p>
238
                      {listMeetingsInPopup && (
×
239
                        <div css={mapPopupMeetingsCss}>
240
                          {data.locations[key].meetings
241
                            .sort((a, b) =>
NEW
UNCOV
242
                              a.start && b.start && a.start > b.start ? 1 : 0
×
243
                            )
244
                            .map((meeting, index) => (
NEW
UNCOV
245
                              <div key={index}>
×
246
                                <time>
247
                                  {meeting.start?.toFormat('t')}
248
                                  <span>{meeting.start?.toFormat('cccc')}</span>
249
                                </time>
250
                                <Link
251
                                  meeting={meeting}
252
                                  setState={setState}
253
                                  state={state}
254
                                />
255
                              </div>
256
                            ))}
257
                        </div>
258
                      )}
259
                      {data.locations[key].directions_url && (
×
260
                        <Button
261
                          href={data.locations[key].directions_url}
262
                          icon="geo"
263
                          text={strings.get_directions}
264
                          type="in-person"
265
                        />
266
                      )}
267
                    </div>
268
                  </Popup>
269
                )}
270
              </div>
271
            ))}
272
            <NavigationControl
273
              showCompass={false}
274
              style={{ top: 10, right: 10 }}
275
            />
276
          </ReactMapGL>
277
        ) : (
278
          <MapContainer
279
            center={
280
              [viewport.latitude, viewport.longitude] as L.LatLngExpression
281
            }
282
            zoom={viewport.zoom}
283
            style={{ height: '100%', width: '100%' }}
284
          >
285
            <TileLayer
286
              url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
287
              attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
288
            />
289
            {data.locationKeys.map(key => (
290
              <LeafletMarker
2✔
291
                key={key}
292
                position={[
293
                  data.locations[key].latitude,
294
                  data.locations[key].longitude,
295
                ]}
296
              >
297
                <LeafletPopup>
298
                  <div css={mapPopupCss}>
299
                    <h2>{data.locations[key].name}</h2>
300
                    <p className="notranslate">
301
                      {data.locations[key].formatted_address}
302
                    </p>
303
                    {listMeetingsInPopup && (
3✔
304
                      <div css={mapPopupMeetingsCss}>
305
                        {data.locations[key].meetings
306
                          .sort((a, b) =>
UNCOV
307
                            a.start && b.start && a.start > b.start ? 1 : 0
×
308
                          )
309
                          .map((meeting, index) => (
310
                            <div key={index}>
1✔
311
                              <time>
312
                                {meeting.start?.toFormat('t')}
313
                                <span>{meeting.start?.toFormat('cccc')}</span>
314
                              </time>
315
                              <Link
316
                                meeting={meeting}
317
                                setState={setState}
318
                                state={state}
319
                              />
320
                            </div>
321
                          ))}
322
                      </div>
323
                    )}
324
                    {data.locations[key].directions_url && (
4✔
325
                      <Button
326
                        href={data.locations[key].directions_url}
327
                        icon="geo"
328
                        text={strings.get_directions}
329
                        type="in-person"
330
                      />
331
                    )}
332
                  </div>
333
                </LeafletPopup>
334
              </LeafletMarker>
335
            ))}
336
          </MapContainer>
337
        ))}
338
    </div>
339
  );
340
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc