@guardian/interactive-component-library 0.5.3 → 0.5.4

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.
@@ -19,14 +19,26 @@ export class TextLayer {
19
19
  * @param {number} [params.opacity=1]
20
20
  * @param {boolean} [params.declutter=true]
21
21
  * @param {boolean} [params.drawCollisionBoxes=false]
22
+ * @param {(feature: import('../Feature').Feature, event: MouseEvent) => void} [params.onClick]
23
+ * @param {(feature: import('../Feature').Feature, event: MouseEvent) => (() => void) | void} [params.onHover]
24
+ * A callback that's called when the mouse hovers over a feature in this layer. The callback is
25
+ * called with the hovered feature, and the mouse event.
26
+ *
27
+ * The callback can optionally return a cleanup function that will be called when the mouse leaves this feature.
28
+ * @param {boolean} [params.restyleOnHover] If true, the layer will re-render when the mouse hovers over a feature.
29
+ *
30
+ * The provided style function will be called with the `isHovering` parameter set to `true` for the hovered feature.
22
31
  */
23
- constructor({ source, style, minZoom, opacity, declutter, drawCollisionBoxes, }: {
32
+ constructor({ source, style, minZoom, opacity, declutter, drawCollisionBoxes, onClick, onHover, restyleOnHover, }: {
24
33
  source: VectorSource;
25
34
  style?: Style | (() => Style);
26
35
  minZoom?: number;
27
36
  opacity?: number;
28
37
  declutter?: boolean;
29
38
  drawCollisionBoxes?: boolean;
39
+ onClick?: (feature: import('../Feature').Feature, event: MouseEvent) => void;
40
+ onHover?: (feature: import('../Feature').Feature, event: MouseEvent) => (() => void) | void;
41
+ restyleOnHover?: boolean;
30
42
  });
31
43
  /**
32
44
  * @type {VectorSource}
@@ -57,6 +69,9 @@ export class TextLayer {
57
69
  * @public
58
70
  */
59
71
  public drawCollisionBoxes: boolean;
72
+ onClick: (feature: import('../Feature').Feature, event: MouseEvent) => void;
73
+ onHover: (feature: import('../Feature').Feature, event: MouseEvent) => (() => void) | void;
74
+ restyleOnHover: boolean;
60
75
  /**
61
76
  * @type {TextLayerRenderer}
62
77
  * @public
@@ -80,7 +95,7 @@ export class TextLayer {
80
95
  }
81
96
  export namespace TextLayer {
82
97
  /** @param {TextLayerComponentProps} props */
83
- function Component({ features: featureCollection, style, minZoom, opacity, declutter, drawCollisionBoxes, }: TextLayerComponentProps): any;
98
+ function Component({ features: featureCollection, style, minZoom, opacity, declutter, drawCollisionBoxes, onClick, onHover, restyleOnHover, }: TextLayerComponentProps): any;
84
99
  namespace Component {
85
100
  let displayName: string;
86
101
  }
@@ -16,7 +16,10 @@ class TextLayer {
16
16
  minZoom,
17
17
  opacity,
18
18
  declutter,
19
- drawCollisionBoxes
19
+ drawCollisionBoxes,
20
+ onClick,
21
+ onHover,
22
+ restyleOnHover
20
23
  }) {
21
24
  const { registerLayer, unregisterLayer } = useContext(MapContext);
22
25
  const layer = useMemo(
@@ -30,7 +33,10 @@ class TextLayer {
30
33
  minZoom,
31
34
  opacity,
32
35
  declutter,
33
- drawCollisionBoxes
36
+ drawCollisionBoxes,
37
+ onClick,
38
+ onHover,
39
+ restyleOnHover
34
40
  });
35
41
  },
36
42
  // eslint-disable-next-line react-hooks/exhaustive-deps
@@ -64,6 +70,15 @@ class TextLayer {
64
70
  * @param {number} [params.opacity=1]
65
71
  * @param {boolean} [params.declutter=true]
66
72
  * @param {boolean} [params.drawCollisionBoxes=false]
73
+ * @param {(feature: import('../Feature').Feature, event: MouseEvent) => void} [params.onClick]
74
+ * @param {(feature: import('../Feature').Feature, event: MouseEvent) => (() => void) | void} [params.onHover]
75
+ * A callback that's called when the mouse hovers over a feature in this layer. The callback is
76
+ * called with the hovered feature, and the mouse event.
77
+ *
78
+ * The callback can optionally return a cleanup function that will be called when the mouse leaves this feature.
79
+ * @param {boolean} [params.restyleOnHover] If true, the layer will re-render when the mouse hovers over a feature.
80
+ *
81
+ * The provided style function will be called with the `isHovering` parameter set to `true` for the hovered feature.
67
82
  */
68
83
  constructor({
69
84
  source,
@@ -71,7 +86,10 @@ class TextLayer {
71
86
  minZoom = 0,
72
87
  opacity = 1,
73
88
  declutter = true,
74
- drawCollisionBoxes = false
89
+ drawCollisionBoxes = false,
90
+ onClick,
91
+ onHover,
92
+ restyleOnHover
75
93
  }) {
76
94
  this.source = source;
77
95
  this._style = style;
@@ -79,6 +97,9 @@ class TextLayer {
79
97
  this.opacity = opacity;
80
98
  this.declutter = declutter;
81
99
  this.drawCollisionBoxes = drawCollisionBoxes;
100
+ this.onClick = onClick;
101
+ this.onHover = onHover;
102
+ this.restyleOnHover = restyleOnHover;
82
103
  this.renderer = new TextLayerRenderer(this);
83
104
  this.dispatcher = new Dispatcher(this);
84
105
  }
@@ -58,8 +58,6 @@ class FeatureRenderer {
58
58
  }
59
59
  if (clipPath) {
60
60
  context.clip();
61
- } else {
62
- context.closePath();
63
61
  }
64
62
  }
65
63
  drawStroke(frameState, context, { style, width: strokeWidth, position }) {
@@ -10,12 +10,21 @@ export class TextLayerRenderer {
10
10
  layer: import('../layers/TextLayer').TextLayer;
11
11
  featureRenderer: FeatureRenderer;
12
12
  _element: HTMLDivElement;
13
+ _mouseInteractionsEnabled: boolean | ((feature: import('../Feature').Feature, event: MouseEvent) => void);
13
14
  /**
14
15
  * @param {import("./MapRenderer").CanvasSingleton} canvasSingleton
15
16
  */
16
17
  renderFrame(frameState: any, canvasSingleton: import('./MapRenderer').CanvasSingleton): HTMLDivElement;
17
18
  getTextElementWithID(id: any): Element;
18
- styleTextElement(element: any, textStyle: any, position: any): {
19
+ /**
20
+ * @param {HTMLDivElement} element
21
+ * @param {import("../styles/Text").Text} textStyle
22
+ * @param {{left: string, top: string}} position
23
+ */
24
+ styleTextElement(element: HTMLDivElement, textStyle: import('../styles/Text').Text, position: {
25
+ left: string;
26
+ top: string;
27
+ }): {
19
28
  width: any;
20
29
  height: any;
21
30
  };
@@ -63,4 +72,6 @@ export class TextLayerRenderer {
63
72
  * @param {import("../styles/Text").IconOptions} icon
64
73
  */
65
74
  drawTextIcon(context: CanvasRenderingContext2D, icon: import('../styles/Text').IconOptions): void;
75
+ attachClickAndHoverListeners(): void;
76
+ _hoveredFeature: import('../Feature').Feature;
66
77
  }
@@ -1,5 +1,6 @@
1
1
  import { FeatureRenderer } from "./FeatureRenderer.js";
2
2
  import { replaceChildren } from "../util/dom.js";
3
+ import { MapEvent } from "../events/MapEvent.js";
3
4
  class TextLayerRenderer {
4
5
  /**
5
6
  * @param {import("../layers/TextLayer").TextLayer} layer
@@ -13,8 +14,10 @@ class TextLayerRenderer {
13
14
  style.position = "absolute";
14
15
  style.width = "100%";
15
16
  style.height = "100%";
16
- style.pointerEvents = "none";
17
17
  style.overflow = "hidden";
18
+ style.pointerEvents = "none";
19
+ this._mouseInteractionsEnabled = this.layer.onClick || this.layer.onHover || this.layer.restyleOnHover;
20
+ this.attachClickAndHoverListeners();
18
21
  }
19
22
  /**
20
23
  * @param {import("./MapRenderer").CanvasSingleton} canvasSingleton
@@ -38,7 +41,11 @@ class TextLayerRenderer {
38
41
  );
39
42
  }
40
43
  const styleFunction = feature.getStyleFunction() || this.layer.getStyleFunction();
41
- const featureStyle = styleFunction(feature, transform.k);
44
+ const featureStyle = styleFunction(
45
+ feature,
46
+ transform.k,
47
+ this._hoveredFeature === feature
48
+ );
42
49
  const textElement = this.getTextElementWithID(feature.uid);
43
50
  textElement.innerText = featureStyle.text.content;
44
51
  const [canvasX, canvasY] = transform.apply(point.coordinates);
@@ -120,9 +127,19 @@ class TextLayerRenderer {
120
127
  if (!textElement) {
121
128
  textElement = document.createElement("div");
122
129
  textElement.id = elementId;
130
+ textElement.dataset.featureId = id;
131
+ }
132
+ if (this._mouseInteractionsEnabled) {
133
+ textElement.style.pointerEvents = "auto";
134
+ textElement.style.cursor = "pointer";
123
135
  }
124
136
  return textElement;
125
137
  }
138
+ /**
139
+ * @param {HTMLDivElement} element
140
+ * @param {import("../styles/Text").Text} textStyle
141
+ * @param {{left: string, top: string}} position
142
+ */
126
143
  styleTextElement(element, textStyle, position) {
127
144
  const style = element.style;
128
145
  style.position = "absolute";
@@ -136,7 +153,20 @@ class TextLayerRenderer {
136
153
  style.lineHeight = textStyle.lineHeight;
137
154
  style.color = textStyle.color;
138
155
  style.textShadow = textStyle.textShadow;
139
- const { width, height } = this.getElementSize(element);
156
+ let { width, height } = this.getElementSize(element);
157
+ if (textStyle.icon) {
158
+ const iconSize = textStyle.icon.size;
159
+ if (textStyle.icon.position === "left") {
160
+ style.paddingLeft = `${iconSize + textStyle.icon.padding * 2}px`;
161
+ } else if (textStyle.icon.position === "right") {
162
+ style.paddingRight = `${iconSize + textStyle.icon.padding * 2}px`;
163
+ }
164
+ const iconSizeHeightDiff = iconSize - height;
165
+ if (iconSizeHeightDiff > 0) {
166
+ style.paddingTop = `${iconSizeHeightDiff / 2}px`;
167
+ style.paddingBottom = `${iconSizeHeightDiff / 2}px`;
168
+ }
169
+ }
140
170
  style.transform = textStyle.getTransform(width, height);
141
171
  return { width, height };
142
172
  }
@@ -176,16 +206,6 @@ class TextLayerRenderer {
176
206
  maxX += textStyle.callout.offsetBy.x;
177
207
  maxY += textStyle.callout.offsetBy.y;
178
208
  }
179
- if (textStyle.icon) {
180
- if (textStyle.icon.position === "left" || textStyle.icon.position === "right") {
181
- maxX += textStyle.icon.size + textStyle.icon.padding;
182
- }
183
- const iconSizeHeightDiff = textStyle.icon.size - dimens.height;
184
- if (iconSizeHeightDiff > 0) {
185
- minY -= iconSizeHeightDiff / 2;
186
- maxY += iconSizeHeightDiff / 2;
187
- }
188
- }
189
209
  minX = Math.floor(minX);
190
210
  minY = Math.floor(minY);
191
211
  maxX = Math.ceil(maxX);
@@ -203,29 +223,11 @@ class TextLayerRenderer {
203
223
  */
204
224
  getElementPosition(textStyle, position) {
205
225
  if (textStyle.callout) {
206
- if (textStyle.icon.position === "left") {
207
- return {
208
- left: `calc(${position.x * 100}% + ${textStyle.callout.offsetBy.x + (textStyle.icon.size + textStyle.icon.padding * 2)}px)`,
209
- top: `calc(${position.y * 100}% + ${textStyle.callout.offsetBy.y}px)`
210
- };
211
- }
212
- if (textStyle.icon.position === "right") {
213
- return {
214
- left: `calc(${position.x * 100}% + ${textStyle.callout.offsetBy.x}px)`,
215
- top: `calc(${position.y * 100}% + ${textStyle.callout.offsetBy.y}px)`
216
- };
217
- }
218
226
  return {
219
227
  left: `calc(${position.x * 100}% + ${textStyle.callout.offsetBy.x}px)`,
220
228
  top: `calc(${position.y * 100}% + ${textStyle.callout.offsetBy.y}px)`
221
229
  };
222
230
  }
223
- if (textStyle.icon && (textStyle.icon.position === "left" || textStyle.icon.position === "right")) {
224
- return {
225
- left: `calc(${position.x * 100}% + ${textStyle.icon.size + textStyle.icon.padding * 2}px)`,
226
- top: `${position.y * 100}%`
227
- };
228
- }
229
231
  return {
230
232
  left: `${position.x * 100}%`,
231
233
  top: `${position.y * 100}%`
@@ -276,6 +278,55 @@ class TextLayerRenderer {
276
278
  }
277
279
  }
278
280
  }
281
+ attachClickAndHoverListeners() {
282
+ if (this.layer.onClick) {
283
+ this._element.addEventListener("click", (event) => {
284
+ if (!event.target) return;
285
+ const clickedFeature = this.layer.source.getFeatures().find((feature) => {
286
+ var _a;
287
+ return ((_a = event.target.dataset) == null ? void 0 : _a.featureId) === feature.uid;
288
+ });
289
+ if (!clickedFeature) return;
290
+ this.layer.onClick(clickedFeature, event);
291
+ });
292
+ }
293
+ if (this.layer.onHover) {
294
+ this._element.addEventListener("mouseover", (event) => {
295
+ if (!event.target) return;
296
+ const hoveredFeature = this.layer.source.getFeatures().find((feature) => {
297
+ var _a;
298
+ return ((_a = event.target.dataset) == null ? void 0 : _a.featureId) === feature.uid;
299
+ });
300
+ if (!hoveredFeature) return;
301
+ const onHoverLeave = this.layer.onHover(hoveredFeature, event);
302
+ if (onHoverLeave) {
303
+ this._element.addEventListener("mouseout", onHoverLeave, {
304
+ once: true
305
+ });
306
+ }
307
+ });
308
+ }
309
+ if (this.layer.restyleOnHover) {
310
+ this._element.addEventListener("mouseover", (event) => {
311
+ if (!event.target) return;
312
+ const hoveredFeature = this.layer.source.getFeatures().find((feature) => {
313
+ var _a;
314
+ return ((_a = event.target.dataset) == null ? void 0 : _a.featureId) === feature.uid;
315
+ });
316
+ if (!hoveredFeature) return;
317
+ this._hoveredFeature = hoveredFeature;
318
+ this.layer.dispatcher.dispatch(MapEvent.CHANGE);
319
+ this._element.addEventListener(
320
+ "mouseout",
321
+ () => {
322
+ this._hoveredFeature = void 0;
323
+ this.layer.dispatcher.dispatch(MapEvent.CHANGE);
324
+ },
325
+ { once: true }
326
+ );
327
+ });
328
+ }
329
+ }
279
330
  }
280
331
  export {
281
332
  TextLayerRenderer
@@ -9,6 +9,8 @@ import { Text } from './Text';
9
9
  * @callback StyleFunction
10
10
  * @param {import("../Feature").Feature} feature The feature to style.
11
11
  * @param {number} zoom The current map zoom level
12
+ * @param {boolean} [isHovering] If the layer has `restyleOnHover` enabled, this will be true if the
13
+ * feature is currently being hovered over.
12
14
  * @returns {Style}
13
15
  */
14
16
  /**
@@ -52,4 +54,4 @@ export class Style {
52
54
  pointRadius: number;
53
55
  clone(): Style;
54
56
  }
55
- export type StyleFunction = (feature: import('../Feature').Feature, zoom: number) => Style;
57
+ export type StyleFunction = (feature: import('../Feature').Feature, zoom: number, isHovering?: boolean) => Style;
@@ -1,9 +1,10 @@
1
- export function ResultSummary({ previous, next, title, text, timestamp, isSlim, styles, }: {
1
+ export function ResultSummary({ previous, next, title, text, timestamp, onClick, isSlim, styles, }: {
2
2
  previous: any;
3
3
  next: any;
4
4
  title: any;
5
5
  text: any;
6
6
  timestamp: any;
7
+ onClick: any;
7
8
  isSlim?: boolean;
8
9
  styles: any;
9
10
  }): import("preact").JSX.Element;
@@ -28,30 +28,38 @@ function ResultSummary({
28
28
  title,
29
29
  text,
30
30
  timestamp,
31
+ onClick,
31
32
  isSlim = false,
32
33
  styles
33
34
  }) {
34
35
  styles = mergeStyles({ ...defaultStyles }, styles);
35
36
  if (isSlim) {
36
37
  let hasChanged = next !== previous;
37
- return /* @__PURE__ */ jsxs("div", { className: `${styles.container} ${styles.containerSlim}`, children: [
38
- hasChanged ? /* @__PURE__ */ jsx(
39
- GradientIcon,
40
- {
41
- previous,
42
- next,
43
- styles: {
44
- previous: styles.previous,
45
- next: styles.next
46
- }
47
- }
48
- ) : /* @__PURE__ */ jsx(CircleIcon, { styles: { circle: `fill-color--${next}` } }),
49
- /* @__PURE__ */ jsxs("p", { className: styles.titleSlim, children: [
50
- title,
51
- " "
52
- ] }),
53
- /* @__PURE__ */ jsx(RelativeTimeSentence, { timeStamp: timestamp })
54
- ] });
38
+ return /* @__PURE__ */ jsxs(
39
+ "div",
40
+ {
41
+ className: `${styles.container} ${styles.containerSlim}`,
42
+ onClick,
43
+ children: [
44
+ hasChanged ? /* @__PURE__ */ jsx(
45
+ GradientIcon,
46
+ {
47
+ previous,
48
+ next,
49
+ styles: {
50
+ previous: styles.previous,
51
+ next: styles.next
52
+ }
53
+ }
54
+ ) : /* @__PURE__ */ jsx(CircleIcon, { styles: { circle: `fill-color--${next}` } }),
55
+ /* @__PURE__ */ jsxs("p", { className: styles.titleSlim, children: [
56
+ title,
57
+ " "
58
+ ] }),
59
+ /* @__PURE__ */ jsx(RelativeTimeSentence, { timeStamp: timestamp })
60
+ ]
61
+ }
62
+ );
55
63
  } else {
56
64
  return /* @__PURE__ */ jsxs("div", { className: styles.container, children: [
57
65
  /* @__PURE__ */ jsx(ControlChange, { previous, next, text: title }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@guardian/interactive-component-library",
3
3
  "private": false,
4
- "version": "0.5.3",
4
+ "version": "0.5.4",
5
5
  "packageManager": "pnpm@8.4.0",
6
6
  "repository": {
7
7
  "type": "git",