@reuters-graphics/graphics-components 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,347 @@
1
+ <!-- @component `ShareBar` shows a row of share buttons (X, Facebook, LinkedIn, Email, Copy link) and an optional "Purchase Licensing Rights" button, echoing the share toolbar on Reuters.com stories. [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-furniture-sharebar--docs) -->
2
+ <script lang="ts">
3
+ import { onDestroy } from 'svelte';
4
+ import Block from '../Block/Block.svelte';
5
+
6
+ interface Props {
7
+ /**
8
+ * Canonical URL to share. When omitted, the live `window.location.href` is
9
+ * read at click time so a shared link reproduces the reader's current view.
10
+ */
11
+ url?: string;
12
+ /** Headline used as the share text and email subject. */
13
+ headline?: string;
14
+ /** Destination for the "Purchase Licensing Rights" button. */
15
+ licensingUrl?: string;
16
+ /** Hide the "Purchase Licensing Rights" button when false. */
17
+ showLicensing?: boolean;
18
+ /** ID on the containing block. */
19
+ id?: string;
20
+ /** Extra classes on the containing block. */
21
+ class?: string;
22
+ }
23
+
24
+ let {
25
+ url,
26
+ headline = '',
27
+ licensingUrl = 'https://www.reutersagency.com/en/licensereuterscontent/?utm_medium=rcom-article-media&utm_campaign=rcom-rcp-lead',
28
+ showLicensing = true,
29
+ id = '',
30
+ class: cls = 'fmy-8',
31
+ }: Props = $props();
32
+
33
+ let copied = $state(false);
34
+ let timer: ReturnType<typeof setTimeout> | null = null;
35
+
36
+ /** Resolve the URL to share, falling back to the live page URL. */
37
+ function shareTarget(): string {
38
+ return url ?? (typeof window !== 'undefined' ? window.location.href : '');
39
+ }
40
+
41
+ /** Open a centered popup for a social share dialog. */
42
+ function openPopup(shareUrl: string) {
43
+ if (typeof window === 'undefined') return;
44
+ window.open(shareUrl, '_blank', 'noopener,noreferrer,width=600,height=600');
45
+ }
46
+
47
+ function shareOnX() {
48
+ const u = encodeURIComponent(shareTarget());
49
+ const text = encodeURIComponent(headline);
50
+ openPopup(`https://twitter.com/intent/tweet?url=${u}&text=${text}`);
51
+ }
52
+
53
+ function shareOnFacebook() {
54
+ const u = encodeURIComponent(shareTarget());
55
+ openPopup(`https://www.facebook.com/sharer/sharer.php?u=${u}`);
56
+ }
57
+
58
+ function shareOnLinkedIn() {
59
+ const u = encodeURIComponent(shareTarget());
60
+ openPopup(`https://www.linkedin.com/sharing/share-offsite/?url=${u}`);
61
+ }
62
+
63
+ function shareViaEmail() {
64
+ if (typeof window === 'undefined') return;
65
+ const subject = encodeURIComponent(headline);
66
+ const body = encodeURIComponent(shareTarget());
67
+ window.location.href = `mailto:?subject=${subject}&body=${body}`;
68
+ }
69
+
70
+ /**
71
+ * Legacy clipboard fallback for contexts where the async Clipboard API is
72
+ * unavailable or blocked — most notably inside a cross-origin iframe (how
73
+ * Reuters embeds graphics), where `navigator.clipboard.writeText` rejects
74
+ * without `clipboard-write` permission. Uses a hidden textarea +
75
+ * `document.execCommand('copy')`, which acts on the document's own selection
76
+ * during the click gesture.
77
+ */
78
+ function legacyCopy(value: string): boolean {
79
+ if (typeof document === 'undefined') return false;
80
+ const textarea = document.createElement('textarea');
81
+ textarea.value = value;
82
+ textarea.setAttribute('readonly', '');
83
+ textarea.style.position = 'fixed';
84
+ textarea.style.top = '-9999px';
85
+ textarea.style.opacity = '0';
86
+ document.body.appendChild(textarea);
87
+ const selection = document.getSelection();
88
+ const previousRange =
89
+ selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
90
+ textarea.select();
91
+ let ok = false;
92
+ try {
93
+ ok = document.execCommand('copy');
94
+ } catch {
95
+ ok = false;
96
+ }
97
+ document.body.removeChild(textarea);
98
+ if (previousRange && selection) {
99
+ selection.removeAllRanges();
100
+ selection.addRange(previousRange);
101
+ }
102
+ return ok;
103
+ }
104
+
105
+ function showCopied() {
106
+ copied = true;
107
+ if (timer) clearTimeout(timer);
108
+ timer = setTimeout(() => {
109
+ copied = false;
110
+ }, 2000);
111
+ }
112
+
113
+ async function copyLink() {
114
+ const value = shareTarget();
115
+ if (!value) return;
116
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
117
+ try {
118
+ await navigator.clipboard.writeText(value);
119
+ showCopied();
120
+ return;
121
+ } catch {
122
+ // Fall through to the legacy path below.
123
+ }
124
+ }
125
+ if (legacyCopy(value)) showCopied();
126
+ }
127
+
128
+ onDestroy(() => {
129
+ if (timer) clearTimeout(timer);
130
+ });
131
+ </script>
132
+
133
+ <Block {id} class="share-bar-block {cls}" width="normal">
134
+ <div class="share-bar" data-testid="ShareBar" dir="ltr">
135
+ <div class="share-buttons" role="group" aria-label="Share this article">
136
+ <button
137
+ type="button"
138
+ class="icon-button"
139
+ aria-label="Share article on X"
140
+ data-testid="share-x"
141
+ onclick={shareOnX}
142
+ >
143
+ <svg viewBox="0 0 30 30" aria-hidden="true" focusable="false">
144
+ <path
145
+ d="M17.923 14.387 25.569 5.5h-1.812l-6.64 7.717L11.817 5.5H5.7l8.018 11.67L5.7 26.49h1.812l7.01-8.15 5.6 8.15h6.116l-8.316-12.102Zm-2.482 2.885-.812-1.162-6.464-9.246h2.783l5.216 7.462.813 1.162 6.78 9.7h-2.782l-5.534-7.915Z"
146
+ />
147
+ </svg>
148
+ </button>
149
+
150
+ <button
151
+ type="button"
152
+ class="icon-button"
153
+ aria-label="Share article on Facebook"
154
+ data-testid="share-facebook"
155
+ onclick={shareOnFacebook}
156
+ >
157
+ <svg viewBox="6 6 20 20" aria-hidden="true" focusable="false">
158
+ <path
159
+ d="M16 6.04001C10.5 6.04001 6 10.53 6 16.06C6 21.06 9.66 25.21 14.44 25.96V18.96H11.9V16.06H14.44V13.85C14.44 11.34 15.93 9.96001 18.22 9.96001C19.31 9.96001 20.45 10.15 20.45 10.15V12.62H19.19C17.95 12.62 17.56 13.39 17.56 14.18V16.06H20.34L19.89 18.96H17.56V25.96C19.9164 25.5879 22.0622 24.3855 23.6099 22.5701C25.1576 20.7546 26.0054 18.4457 26 16.06C26 10.53 21.5 6.04001 16 6.04001Z"
160
+ />
161
+ </svg>
162
+ </button>
163
+
164
+ <button
165
+ type="button"
166
+ class="icon-button"
167
+ aria-label="Share article on LinkedIn"
168
+ data-testid="share-linkedin"
169
+ onclick={shareOnLinkedIn}
170
+ >
171
+ <svg viewBox="0 0 21 21" aria-hidden="true" focusable="false">
172
+ <path
173
+ d="M19.031 0c1.034 0 1.888.807 1.964 1.822L21 1.97V19.03a1.975 1.975 0 0 1-1.822 1.964L19.03 21H1.97a1.975 1.975 0 0 1-1.964-1.822L0 19.03V1.97C0 .935.807.08 1.822.005L1.97 0H19.03ZM6.3 7.875H3.15v10.063H6.3V7.874Zm7.875-.175c-1.575 0-2.538.788-2.975 1.575v-1.4H8.225v10.063h3.15V12.95c0-1.313.175-2.537 1.838-2.537 1.575 0 1.575 1.487 1.575 2.624v4.9h3.15v-5.425c0-2.712-.613-4.812-3.763-4.812ZM4.637 2.8c-1.05 0-1.837.875-1.837 1.838 0 1.05.875 1.837 1.838 1.837 1.05 0 1.837-.787 1.837-1.837 0-1.05-.875-1.838-1.837-1.838Z"
174
+ />
175
+ </svg>
176
+ </button>
177
+
178
+ <button
179
+ type="button"
180
+ class="icon-button"
181
+ aria-label="Email article"
182
+ data-testid="share-email"
183
+ onclick={shareViaEmail}
184
+ >
185
+ <svg viewBox="0 0 21 18" aria-hidden="true" focusable="false">
186
+ <path
187
+ d="M20.125 9c.449 0 .819.347.87.795L21 9.9v5.4c0 1.438-1.093 2.613-2.47 2.695l-.155.005H2.625c-1.398 0-2.54-1.124-2.62-2.541L0 15.3V9.9c0-.497.392-.9.875-.9.449 0 .819.347.87.795l.005.105v5.4c0 .462.338.842.773.894l.102.006h15.75a.884.884 0 0 0 .87-.795l.005-.105V9.9c0-.497.392-.9.875-.9Zm-1.75-9C19.825 0 21 1.209 21 2.7v2.7c0 .33-.175.632-.456.79l-9.625 5.4a.854.854 0 0 1-.838 0L.456 6.19A.904.904 0 0 1 0 5.4V2.7C0 1.209 1.175 0 2.625 0h15.75Zm0 1.8H2.625a.888.888 0 0 0-.875.9v2.166l8.75 4.909 8.75-4.91V2.7a.892.892 0 0 0-.773-.894l-.102-.006Z"
188
+ />
189
+ </svg>
190
+ </button>
191
+
192
+ <span class="icon-button-wrap">
193
+ <button
194
+ type="button"
195
+ class="icon-button"
196
+ aria-label="Copy link"
197
+ data-testid="share-link"
198
+ onclick={copyLink}
199
+ >
200
+ <svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">
201
+ <path
202
+ d="M14.47 1.458a4.163 4.163 0 0 0-5.901 0l-1.28 1.209a.688.688 0 0 0 0 .995c.284.285.71.285.995 0l1.21-1.209a2.878 2.878 0 0 1 3.982 0c1.138 1.067 1.137 2.916.07 3.983l-2.133 2.133-.284.284c-1.28.925-3.058.711-3.982-.569-.214-.284-.711-.355-.996-.142-.285.213-.356.71-.142.996a4.242 4.242 0 0 0 3.413 1.706c.853 0 1.777-.284 2.56-.853.142-.142.284-.284.497-.427l2.134-2.133c1.636-1.636 1.564-4.41-.143-5.973Z"
203
+ />
204
+ <path
205
+ d="m7.644 12.338-1.209 1.209a2.878 2.878 0 0 1-3.982 0c-1.138-1.067-1.138-2.916-.071-3.983l2.133-2.133.284-.284c.64-.427 1.351-.64 2.134-.57.782.143 1.422.498 1.849 1.138.213.284.71.356.996.143.284-.214.355-.711.142-.996-.71-.924-1.707-1.493-2.773-1.636-1.067-.213-2.205.071-3.13.783l-.426.426L1.458 8.57c-1.636 1.706-1.565 4.409.07 6.044.854.782 1.92 1.209 2.987 1.209 1.067 0 2.134-.427 2.987-1.21l1.209-1.208a.688.688 0 0 0 0-.996c-.284-.285-.782-.356-1.067-.07Z"
206
+ />
207
+ </svg>
208
+ </button>
209
+ {#if copied}
210
+ <span class="tooltip" aria-hidden="true">Link copied</span>
211
+ {/if}
212
+ <span class="visually-hidden" role="status" aria-live="polite">
213
+ {copied ? 'Copied to clipboard' : ''}
214
+ </span>
215
+ </span>
216
+ </div>
217
+
218
+ {#if showLicensing}
219
+ <a
220
+ class="licensing"
221
+ href={licensingUrl}
222
+ target="_blank"
223
+ rel="noopener noreferrer"
224
+ dir="ltr"
225
+ data-testid="licensing-link"
226
+ >
227
+ Purchase Licensing Rights
228
+ </a>
229
+ {/if}
230
+ </div>
231
+ </Block>
232
+
233
+ <style>.share-bar {
234
+ display: flex;
235
+ align-items: center;
236
+ justify-content: space-between;
237
+ flex-wrap: wrap;
238
+ gap: 0.75rem;
239
+ }
240
+
241
+ .share-buttons {
242
+ display: inline-flex;
243
+ align-items: center;
244
+ gap: 0.5rem;
245
+ flex-wrap: wrap;
246
+ }
247
+
248
+ .icon-button-wrap {
249
+ position: relative;
250
+ display: inline-flex;
251
+ }
252
+
253
+ .icon-button {
254
+ width: 2.5rem;
255
+ height: 2.5rem;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ padding: 0;
260
+ border: 1px solid var(--theme-colour-brand-rules, #d3d3d3);
261
+ border-radius: 4px;
262
+ background: var(--theme-colour-background, #fff);
263
+ color: var(--theme-colour-text-secondary, #666);
264
+ cursor: pointer;
265
+ transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
266
+ }
267
+
268
+ .icon-button:hover,
269
+ .icon-button:focus-visible {
270
+ color: var(--theme-colour-text-primary, #121212);
271
+ border-color: var(--theme-colour-text-secondary, #666);
272
+ background: rgba(0, 0, 0, 0.03);
273
+ }
274
+
275
+ .icon-button svg {
276
+ display: block;
277
+ width: 1.125rem;
278
+ height: 1.125rem;
279
+ fill: currentColor;
280
+ }
281
+
282
+ .tooltip {
283
+ position: absolute;
284
+ bottom: calc(100% + 0.375rem);
285
+ left: 50%;
286
+ transform: translateX(-50%);
287
+ padding: 0.25rem 0.5rem;
288
+ border-radius: 4px;
289
+ background: var(--theme-colour-text-primary, #121212);
290
+ color: var(--theme-colour-background, #fff);
291
+ font-size: 0.75rem;
292
+ line-height: 1;
293
+ white-space: nowrap;
294
+ pointer-events: none;
295
+ z-index: 1;
296
+ }
297
+
298
+ .tooltip::after {
299
+ content: "";
300
+ position: absolute;
301
+ top: 100%;
302
+ left: 50%;
303
+ transform: translateX(-50%);
304
+ border: 4px solid transparent;
305
+ border-top-color: var(--theme-colour-text-primary, #121212);
306
+ }
307
+
308
+ .licensing {
309
+ display: inline-flex;
310
+ align-items: center;
311
+ height: 2.5rem;
312
+ padding: 0 1rem;
313
+ border: 1px solid var(--theme-colour-brand-rules, #d3d3d3);
314
+ border-radius: 4px;
315
+ background: var(--theme-colour-background, #fff);
316
+ color: var(--theme-colour-text-secondary, #666);
317
+ font-size: 0.875rem;
318
+ line-height: 1;
319
+ white-space: nowrap;
320
+ text-decoration: none;
321
+ transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
322
+ }
323
+
324
+ .licensing:hover,
325
+ .licensing:focus-visible {
326
+ color: var(--theme-colour-text-primary, #121212);
327
+ border-color: var(--theme-colour-text-secondary, #666);
328
+ background: rgba(0, 0, 0, 0.03);
329
+ }
330
+
331
+ .visually-hidden {
332
+ position: absolute;
333
+ width: 1px;
334
+ height: 1px;
335
+ padding: 0;
336
+ margin: -1px;
337
+ overflow: hidden;
338
+ clip: rect(0, 0, 0, 0);
339
+ white-space: nowrap;
340
+ border: 0;
341
+ }
342
+
343
+ @media (max-width: 767px) {
344
+ .share-bar {
345
+ align-items: flex-start;
346
+ }
347
+ }</style>
@@ -0,0 +1,21 @@
1
+ interface Props {
2
+ /**
3
+ * Canonical URL to share. When omitted, the live `window.location.href` is
4
+ * read at click time so a shared link reproduces the reader's current view.
5
+ */
6
+ url?: string;
7
+ /** Headline used as the share text and email subject. */
8
+ headline?: string;
9
+ /** Destination for the "Purchase Licensing Rights" button. */
10
+ licensingUrl?: string;
11
+ /** Hide the "Purchase Licensing Rights" button when false. */
12
+ showLicensing?: boolean;
13
+ /** ID on the containing block. */
14
+ id?: string;
15
+ /** Extra classes on the containing block. */
16
+ class?: string;
17
+ }
18
+ /** `ShareBar` shows a row of share buttons (X, Facebook, LinkedIn, Email, Copy link) and an optional "Purchase Licensing Rights" button, echoing the share toolbar on Reuters.com stories. [Read the docs.](https://reuters-graphics.github.io/graphics-components/?path=/docs/components-page-furniture-sharebar--docs) */
19
+ declare const ShareBar: import("svelte").Component<Props, {}, "">;
20
+ type ShareBar = ReturnType<typeof ShareBar>;
21
+ export default ShareBar;
@@ -21,6 +21,7 @@
21
21
  import GraphicBlock from '../GraphicBlock/GraphicBlock.svelte';
22
22
  import type { ContainerWidth } from '../@types/global';
23
23
  import type { ProjectionSpecification } from 'maplibre-gl';
24
+ import { emphasizePlaceLabels } from './labels';
24
25
  import 'maplibre-gl/dist/maplibre-gl.css';
25
26
 
26
27
  interface Props {
@@ -65,6 +66,13 @@
65
66
  * Map style URL
66
67
  */
67
68
  styleUrl?: string;
69
+ /**
70
+ * Darken the basemap's place labels and give them a strong white halo, so
71
+ * city/region names stay readable over colored data layers. Applied once on
72
+ * map load; a no-op on styles without `place` labels (e.g. non-default
73
+ * `styleUrl`s), so it degrades gracefully.
74
+ */
75
+ emphasizeLabels?: boolean;
68
76
  /**
69
77
  * Map height (default: '500px')
70
78
  */
@@ -101,6 +109,7 @@
101
109
  projection,
102
110
  interactive = true,
103
111
  styleUrl = 'https://graphics.thomsonreuters.com/reuters-protomaps/style.json',
112
+ emphasizeLabels = false,
104
113
  height = '500px',
105
114
  width = 'normal',
106
115
  textWidth = 'normal',
@@ -155,6 +164,28 @@
155
164
  );
156
165
  }
157
166
 
167
+ // Darken the basemap's place labels so they read over data layers. Some
168
+ // styles keep applying their own label paint for a moment after the style
169
+ // loads, so a single call at `load` can be overridden and leave the
170
+ // labels their default (lighter) color. Apply on the first `styledata`
171
+ // where the place labels are present, then stop listening.
172
+ if (emphasizeLabels) {
173
+ const applyLabelEmphasis = () => {
174
+ if (!map) return;
175
+ const hasPlaceLabels = map
176
+ .getStyle()
177
+ ?.layers?.some(
178
+ (l) =>
179
+ l.type === 'symbol' &&
180
+ (l as { 'source-layer'?: string })['source-layer'] === 'place'
181
+ );
182
+ if (!hasPlaceLabels) return;
183
+ emphasizePlaceLabels(map);
184
+ map.off('styledata', applyLabelEmphasis);
185
+ };
186
+ map.on('styledata', applyLabelEmphasis);
187
+ }
188
+
158
189
  // Call the callback when map is ready
159
190
  map.on('load', () => {
160
191
  if (!map) return;
@@ -45,6 +45,13 @@ interface Props {
45
45
  * Map style URL
46
46
  */
47
47
  styleUrl?: string;
48
+ /**
49
+ * Darken the basemap's place labels and give them a strong white halo, so
50
+ * city/region names stay readable over colored data layers. Applied once on
51
+ * map load; a no-op on styles without `place` labels (e.g. non-default
52
+ * `styleUrl`s), so it degrades gracefully.
53
+ */
54
+ emphasizeLabels?: boolean;
48
55
  /**
49
56
  * Map height (default: '500px')
50
57
  */
@@ -4,6 +4,7 @@
4
4
  import type { Map as MaplibreMap, GeoJSONSource } from 'maplibre-gl';
5
5
  import type { Writable } from 'svelte/store';
6
6
  import type { GeoJSON } from 'geojson';
7
+ import { findFirstSymbolLayerId } from './labels';
7
8
 
8
9
  interface Props {
9
10
  /**
@@ -39,6 +40,12 @@
39
40
  * Layer to insert before (for layer ordering)
40
41
  */
41
42
  beforeId?: string;
43
+ /**
44
+ * Insert this layer beneath the basemap's labels, so place names and other
45
+ * symbol layers stay readable on top of it. Ignored when `beforeId` is set
46
+ * (that takes precedence). A no-op on styles with no symbol layers.
47
+ */
48
+ beneathLabels?: boolean;
42
49
  /**
43
50
  * Minimum zoom level to display layer
44
51
  */
@@ -60,6 +67,7 @@
60
67
  paint = {},
61
68
  layout = {},
62
69
  beforeId,
70
+ beneathLabels = false,
63
71
  minZoom,
64
72
  maxZoom,
65
73
  filter,
@@ -146,7 +154,11 @@
146
154
  if (maxZoom !== undefined) layerConfig.maxzoom = maxZoom;
147
155
  if (filter) layerConfig.filter = filter;
148
156
 
149
- map.addLayer(layerConfig as never, beforeId);
157
+ // An explicit `beforeId` wins; otherwise `beneathLabels` inserts the
158
+ // layer just below the first symbol (label) layer so labels stay on top.
159
+ const insertBefore =
160
+ beforeId ?? (beneathLabels ? findFirstSymbolLayerId(map) : undefined);
161
+ map.addLayer(layerConfig as never, insertBefore);
150
162
  }
151
163
 
152
164
  isInitialized = true;
@@ -24,6 +24,12 @@ interface Props {
24
24
  * Layer to insert before (for layer ordering)
25
25
  */
26
26
  beforeId?: string;
27
+ /**
28
+ * Insert this layer beneath the basemap's labels, so place names and other
29
+ * symbol layers stay readable on top of it. Ignored when `beforeId` is set
30
+ * (that takes precedence). A no-op on styles with no symbol layers.
31
+ */
32
+ beneathLabels?: boolean;
27
33
  /**
28
34
  * Minimum zoom level to display layer
29
35
  */
@@ -0,0 +1,34 @@
1
+ import type { Map as MaplibreMap } from 'maplibre-gl';
2
+ /**
3
+ * Find the first symbol (label) layer in the map's current style, or undefined.
4
+ * Symbol layers are the basemap's place names, road labels, etc. Inserting a
5
+ * data layer *before* this id keeps every label above the data.
6
+ *
7
+ * @see https://docs.mapbox.com/mapbox-gl-js/example/geojson-layer-in-stack/
8
+ */
9
+ export declare function findFirstSymbolLayerId(map: MaplibreMap): string | undefined;
10
+ /** Options for {@link emphasizePlaceLabels}. */
11
+ export interface EmphasizeLabelsOptions {
12
+ /** Text color for place labels. Default `#1a1a1a` (near-black). */
13
+ textColor?: string;
14
+ /** Halo color. Default solid white. */
15
+ haloColor?: string;
16
+ /** Halo width, in px. Default `1.6`. */
17
+ haloWidth?: number;
18
+ }
19
+ /**
20
+ * Darken the basemap's populated-place labels (cities, towns, capitals, …) and
21
+ * give them a strong white halo so they stay legible on top of colored data
22
+ * overlays.
23
+ *
24
+ * The default protomaps style fades place labels with a zoom-driven
25
+ * `text-opacity` (city labels only reach full strength around zoom 9), so this
26
+ * also pins `text-opacity` to 1 — otherwise the near-black color reads as gray
27
+ * at the low-to-mid zooms typical of a choropleth.
28
+ *
29
+ * Targets the `place` source-layer symbol layers of the default Reuters
30
+ * protomaps style. On a style that doesn't have them it's a **no-op** (the
31
+ * per-layer checks and `try/catch` make it degrade gracefully), so it's safe to
32
+ * call regardless of `styleUrl`.
33
+ */
34
+ export declare function emphasizePlaceLabels(map: MaplibreMap, opts?: EmphasizeLabelsOptions): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Find the first symbol (label) layer in the map's current style, or undefined.
3
+ * Symbol layers are the basemap's place names, road labels, etc. Inserting a
4
+ * data layer *before* this id keeps every label above the data.
5
+ *
6
+ * @see https://docs.mapbox.com/mapbox-gl-js/example/geojson-layer-in-stack/
7
+ */
8
+ export function findFirstSymbolLayerId(map) {
9
+ const layers = map.getStyle()?.layers;
10
+ if (!layers)
11
+ return undefined;
12
+ for (const layer of layers) {
13
+ if (layer.type === 'symbol')
14
+ return layer.id;
15
+ }
16
+ return undefined;
17
+ }
18
+ /**
19
+ * Darken the basemap's populated-place labels (cities, towns, capitals, …) and
20
+ * give them a strong white halo so they stay legible on top of colored data
21
+ * overlays.
22
+ *
23
+ * The default protomaps style fades place labels with a zoom-driven
24
+ * `text-opacity` (city labels only reach full strength around zoom 9), so this
25
+ * also pins `text-opacity` to 1 — otherwise the near-black color reads as gray
26
+ * at the low-to-mid zooms typical of a choropleth.
27
+ *
28
+ * Targets the `place` source-layer symbol layers of the default Reuters
29
+ * protomaps style. On a style that doesn't have them it's a **no-op** (the
30
+ * per-layer checks and `try/catch` make it degrade gracefully), so it's safe to
31
+ * call regardless of `styleUrl`.
32
+ */
33
+ export function emphasizePlaceLabels(map, opts = {}) {
34
+ const { textColor = '#1a1a1a', haloColor = 'rgba(255, 255, 255, 1)', haloWidth = 1.6, } = opts;
35
+ const layers = map.getStyle()?.layers;
36
+ if (!layers)
37
+ return;
38
+ for (const layer of layers) {
39
+ if (layer.type !== 'symbol')
40
+ continue;
41
+ const sourceLayer = layer['source-layer'];
42
+ if (sourceLayer !== 'place')
43
+ continue;
44
+ try {
45
+ map.setPaintProperty(layer.id, 'text-color', textColor);
46
+ map.setPaintProperty(layer.id, 'text-halo-color', haloColor);
47
+ map.setPaintProperty(layer.id, 'text-halo-width', haloWidth);
48
+ map.setPaintProperty(layer.id, 'text-halo-blur', 0);
49
+ map.setPaintProperty(layer.id, 'text-opacity', 1);
50
+ }
51
+ catch {
52
+ /* layer isn't paintable in this style; skip it */
53
+ }
54
+ }
55
+ }
package/dist/index.d.ts CHANGED
@@ -46,6 +46,7 @@ export { default as ReutersLogo } from './components/ReutersLogo/ReutersLogo.sve
46
46
  export { default as Scroller } from './components/Scroller/Scroller.svelte';
47
47
  export { default as SearchInput } from './components/SearchInput/SearchInput.svelte';
48
48
  export { default as SEO } from './components/SEO/SEO.svelte';
49
+ export { default as ShareBar } from './components/ShareBar/ShareBar.svelte';
49
50
  export { default as SimpleTimeline } from './components/SimpleTimeline/SimpleTimeline.svelte';
50
51
  export { default as SiteFooter } from './components/SiteFooter/SiteFooter.svelte';
51
52
  export { default as SiteHeader } from './components/SiteHeader/SiteHeader.svelte';
package/dist/index.js CHANGED
@@ -49,6 +49,7 @@ export { default as ReutersLogo } from './components/ReutersLogo/ReutersLogo.sve
49
49
  export { default as Scroller } from './components/Scroller/Scroller.svelte';
50
50
  export { default as SearchInput } from './components/SearchInput/SearchInput.svelte';
51
51
  export { default as SEO } from './components/SEO/SEO.svelte';
52
+ export { default as ShareBar } from './components/ShareBar/ShareBar.svelte';
52
53
  export { default as SimpleTimeline } from './components/SimpleTimeline/SimpleTimeline.svelte';
53
54
  export { default as SiteFooter } from './components/SiteFooter/SiteFooter.svelte';
54
55
  export { default as SiteHeader } from './components/SiteHeader/SiteHeader.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reuters-graphics/graphics-components",
3
- "version": "3.6.0",
3
+ "version": "3.8.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "homepage": "https://reuters-graphics.github.io/graphics-components",