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

code4recovery / tsml-ui / 14884090827

07 May 2025 01:04PM UTC coverage: 60.516% (-0.5%) from 61.038%
14884090827

Pull #447

github

web-flow
Merge f56b42432 into 88b96cb85
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 %.

5 of 11 new or added lines in 2 files covered. (45.45%)

108 existing lines in 7 files now uncovered.

667 of 1010 relevant lines covered (66.04%)

9.4 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 { MapContainer, TileLayer, Marker as LeafletMarker, Popup as LeafletPopup } from 'react-leaflet';
15
import L from 'leaflet';
16
import 'leaflet/dist/leaflet.css';
17

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

25
type Locations = {
26
  [index: string]: {
27
    directions_url: string;
28
    formatted_address: string;
29
    latitude: number;
30
    longitude: number;
31
    meetings: Meeting[];
32
    name?: string;
33
  };
34
};
35

36
type Bounds = {
37
  north?: number;
38
  east?: number;
39
  south?: number;
40
  west?: number;
41
};
42

43
type Viewport = {
44
  width: number;
45
  height: number;
46
  zoom: number;
47
  latitude: number;
48
  longitude: number;
49
};
50

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

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

101
  //reset bounds and locations when filteredSlugs changes
102
  useEffect(() => {
22✔
103
    const locations: Locations = {};
10✔
104
    const bounds: Bounds = {};
10✔
105

106
    filteredSlugs.forEach(slug => {
10✔
107
      const meeting = state.meetings[slug];
9✔
108

109
      if (meeting?.latitude && meeting?.longitude && meeting?.isInPerson) {
9✔
110
        const coords = meeting.latitude + ',' + meeting.longitude;
2✔
111

112
        //create a new pin
113
        if (!locations[coords]) {
2!
114
          locations[coords] = {
2✔
115
            directions_url: formatDirectionsUrl(meeting),
116
            formatted_address: meeting.formatted_address,
117
            latitude: meeting.latitude,
118
            longitude: meeting.longitude,
119
            meetings: [],
120
            name: meeting.location,
121
          };
122
        }
123

124
        //expand bounds
125
        if (!bounds.north || meeting.latitude > bounds.north)
2!
126
          bounds.north = meeting.latitude;
2✔
127
        if (!bounds.south || meeting.latitude < bounds.south)
2!
128
          bounds.south = meeting.latitude;
2✔
129
        if (!bounds.east || meeting.longitude > bounds.east)
2!
130
          bounds.east = meeting.longitude;
2✔
131
        if (!bounds.west || meeting.longitude < bounds.west)
2!
132
          bounds.west = meeting.longitude;
2✔
133

134
        //add meeting to pin
135
        locations[coords].meetings.push(meeting);
2✔
136
      }
137
    });
138

139
    //quick reference array
140
    const locationKeys: string[] = Object.keys(locations).sort(
10✔
141
      (a, b) => locations[b].latitude - locations[a].latitude
×
142
    );
143

144
    //set state (sort so southern pins appear in front)
145
    setData({
10✔
146
      bounds: bounds,
147
      locations: locations,
148
      locationKeys: locationKeys,
149
    });
150

151
    //show popup if only one
152
    if (locationKeys.length === 1) {
10✔
153
      setPopup(locationKeys[0]);
2✔
154
    }
155
  }, [filteredSlugs]);
156

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

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