diff --git a/README.md b/README.md index a94734a..40325e8 100644 --- a/README.md +++ b/README.md @@ -84,10 +84,10 @@ map.on('load', () => { }) // Create whatever HTML element you want as Cluster -const clusterRender = (element: HTMLDivElement, props: MapGeoJSONFeature['properties']): void => {} +const clusterRender = (element: HTMLDivElement, props: NonNullable): void => {} // Create whatever HTML element you want as individual Marker -const markerRender = (element: HTMLDivElement, feature: MapGeoJSONFeature, markerSize: number): void => {} +const markerRender = (element: HTMLDivElement, feature: GeoJSON.Feature, markerSize: number): void => {} // Create whatever HTML element you want as Pin Marker const pinMarkerRender = (coords: LngLatLike, offset: Point): Marker => {} @@ -117,12 +117,12 @@ Create a new Maplibre GL JS plugin for feature (cluster / individual marker) ren |-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|----------|-----------------------------------------------------| | clusterMaxZoom | `number` | Maximal zoom level at which we force the rendering of the Unfolded Cluster | ❌ | `17` | | clusterMinZoom | `number` | Minimal zoom level at which we force the rendering of the Unfolded Cluster | ❌ | `0` | -| clusterRenderFn | `(element: HTMLDivElement, props: MapGeoJSONFeature['properties']): void` | Cluster render function | ❌ | `src/utils/helpers.ts/clusterRenderDefault()` | +| clusterRenderFn | `(element: HTMLDivElement, props: NonNullable): void` | Cluster render function | ❌ | `src/utils/helpers.ts/clusterRenderDefault()` | | fitBoundsOptions | [`FitBoundsOptions`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/FitBoundsOptions) | Options for [Map#fitBounds](https://maplibre.org/maplibre-gl-js/docs/API/classes/Map/#fitbounds) method | ❌ | `{ padding: 20 }` | -| initialFeature | [`MapGeoJSONFeature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapGeoJSONFeature/) | Feature to select on initial rendering | ❌ | `undefined` | -| markerRenderFn | `(element: HTMLDivElement, feature: MapGeoJSONFeature, markerSize: number): void` | Individual Marker render function | ❌ | `src/utils/helpers.ts/markerRenderDefault()` | +| initialFeature | [`GeoJSON.Feature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/GeoJSON.Feature/) | Feature to select on initial rendering | ❌ | `undefined` | +| markerRenderFn | `(element: HTMLDivElement, feature: GeoJSON.Feature, markerSize: number): void` | Individual Marker render function | ❌ | `src/utils/helpers.ts/markerRenderDefault()` | | markerSize | `number` (in px) | Size of Marker | ❌ | `24` | -| unfoldedClusterRenderFn | `(parent: HTMLDivElement, items: MapGeoJSONFeature[], markerSize: number, renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, clickHandler: (e: Event, feature: MapGeoJSONFeature) => void) => void` | Unfolded Cluster render function | ❌ | `src/utils/helpers.ts/unfoldedClusterRenderSmart()` | +| unfoldedClusterRenderFn | `(parent: HTMLDivElement, items: GeoJSON.Feature[], markerSize: number, renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, clickHandler: (e: Event, feature: GeoJSON.Feature) => void) => void` | Unfolded Cluster render function | ❌ | `src/utils/helpers.ts/unfoldedClusterRenderSmart()` | | unfoldedClusterRenderSmart | Mix between Circular and HexaShape shape Unfolded Cluster render function | - | - | - | | unfoldedClusterRenderGrid | Grid shape Unfolded Cluster render function function | - | - | - | | unfoldedClusterRenderCircle | Circular shape Unfolded Cluster render function function | - | - | - | @@ -133,10 +133,10 @@ Create a new Maplibre GL JS plugin for feature (cluster / individual marker) ren #### Methods | Name | Type | Description | |----------------------|------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| addEventListener | ('feature-click', (e: Event) => void) | Listen to feature click and return a [`MapGeoJSONFeature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapGeoJSONFeature/) from `e.detail.selectedFeature` for external control. | +| addEventListener | ('feature-click', (e: Event) => void) | Listen to feature click and return a [`GeoJSON.Feature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/GeoJSON.Feature/) from `e.detail.selectedFeature` for external control. | | resetSelectedFeature | () => void | Remove selected feature and associated Pin Marker | | setBoundsOptions | (options: [`FitBoundsOptions`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/FitBoundsOptions)) => void | Update Map's visible area | -| setSelectedFeature | (feature: [`MapGeoJSONFeature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapGeoJSONFeature/)) => void | Set selected feature and display Pin Marker on top of it | +| setSelectedFeature | (feature: [`GeoJSON.Feature`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/GeoJSON.Feature/)) => void | Set selected feature and display Pin Marker on top of it | ## Dev Install dependencies diff --git a/src/teritorio-cluster.ts b/src/teritorio-cluster.ts index 90deeac..ec22afa 100644 --- a/src/teritorio-cluster.ts +++ b/src/teritorio-cluster.ts @@ -1,4 +1,4 @@ -import type { FitBoundsOptions, GeoJSONSource, LngLatLike, MapGeoJSONFeature, MapSourceDataEvent } from 'maplibre-gl' +import type { FitBoundsOptions, GeoJSONSource, LngLatLike, MapSourceDataEvent } from 'maplibre-gl' import { Marker, Point } from 'maplibre-gl' import { clusterRenderDefault, @@ -13,22 +13,22 @@ import { featureCollection } from '@turf/helpers' type UnfoldedCluster = ( ( parent: HTMLDivElement, - items: MapGeoJSONFeature[], + items: GeoJSON.Feature[], markerSize: number, - renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, - clickHandler: (e: Event, feature: MapGeoJSONFeature) => void + renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, + clickHandler: (e: Event, feature: GeoJSON.Feature) => void ) => void ) type ClusterRender = ( ( element: HTMLDivElement, - props: MapGeoJSONFeature['properties'] + props: NonNullable ) => void ) type MarkerRender = ( ( element: HTMLDivElement, - feature: MapGeoJSONFeature, + feature: GeoJSON.Feature, markerSize: number ) => void ) @@ -38,20 +38,20 @@ type PinMarkerRender = ( offset: Point ) => Marker ) -type FeatureInClusterMatch = { clusterId: string, feature: MapGeoJSONFeature } -type FeatureMatch = FeatureInClusterMatch | MapGeoJSONFeature +type FeatureInClusterMatch = { clusterId: string, feature: GeoJSON.Feature } +type FeatureMatch = FeatureInClusterMatch | GeoJSON.Feature const UnfoldedClusterClass = 'teritorio-unfolded-cluster' export class TeritorioCluster extends EventTarget { map: maplibregl.Map - clusterLeaves: Map + clusterLeaves: Map clusterMaxZoom: number clusterMinZoom: number clusterRender?: ClusterRender - featuresMap: Map + featuresMap: Map fitBoundsOptions: FitBoundsOptions - initialFeature?: MapGeoJSONFeature + initialFeature?: GeoJSON.Feature markerRender?: MarkerRender markerSize: number markersOnScreen: Map @@ -72,7 +72,7 @@ export class TeritorioCluster extends EventTarget { clusterMinZoom?: number, clusterRenderFn?: ClusterRender, fitBoundsOptions?: FitBoundsOptions, - initialFeature?: MapGeoJSONFeature, + initialFeature?: GeoJSON.Feature, markerRenderFn?: MarkerRender, markerSize?: number unfoldedClusterRenderFn?: UnfoldedCluster, @@ -83,11 +83,11 @@ export class TeritorioCluster extends EventTarget { super() this.map = map - this.clusterLeaves = new Map() + this.clusterLeaves = new Map() this.clusterMaxZoom = options?.clusterMaxZoom || 17 this.clusterMinZoom = options?.clusterMinZoom || 0 this.clusterRender = options?.clusterRenderFn - this.featuresMap = new Map() + this.featuresMap = new Map() this.fitBoundsOptions = options?.fitBoundsOptions || { padding: 20 } this.initialFeature = options?.initialFeature this.markerRender = options?.markerRenderFn @@ -180,7 +180,7 @@ export class TeritorioCluster extends EventTarget { return new Point(x - clusterXCenter + (width / 2), y - clusterYCenter + (height / 2)) } - #featureClickHandler = (e: Event, feature: MapGeoJSONFeature) => { + #featureClickHandler = (e: Event, feature: GeoJSON.Feature) => { e.stopPropagation() if (!(e.currentTarget instanceof HTMLElement) || this.selectedFeatureId === getFeatureId(feature)) @@ -211,7 +211,7 @@ export class TeritorioCluster extends EventTarget { } } - #fitBoundsToClusterLeaves = (features: MapGeoJSONFeature[]) => { + #fitBoundsToClusterLeaves = (features: GeoJSON.Feature[]) => { const bounds = bbox(featureCollection(features)) this.map.fitBounds(bounds as [number, number, number, number], this.fitBoundsOptions) @@ -254,7 +254,7 @@ export class TeritorioCluster extends EventTarget { this.ticking = true } - #renderCluster = (id: string, props: MapGeoJSONFeature['properties']) => { + #renderCluster = (id: string, props: NonNullable) => { const element = document.createElement('div') element.id = id @@ -278,7 +278,7 @@ export class TeritorioCluster extends EventTarget { return element } - #renderMarker = (feature: MapGeoJSONFeature) => { + #renderMarker = (feature: GeoJSON.Feature) => { const element = document.createElement('div') element.id = getFeatureId(feature) @@ -297,7 +297,7 @@ export class TeritorioCluster extends EventTarget { this.pinMarker.addTo(this.map) } - #renderUnfoldedCluster = (id: string, leaves: MapGeoJSONFeature[]) => { + #renderUnfoldedCluster = (id: string, leaves: GeoJSON.Feature[]) => { const element = document.createElement('div') element.id = id element.classList.add(UnfoldedClusterClass) @@ -348,7 +348,7 @@ export class TeritorioCluster extends EventTarget { // Get cluster's leaves if (feature.properties.cluster) { const source = this.map.getSource(this.sourceId) as GeoJSONSource - const leaves = await source.getClusterLeaves(Number.parseInt(id), feature.properties.point_count, 0) as MapGeoJSONFeature[] + const leaves = await source.getClusterLeaves(Number.parseInt(id), feature.properties.point_count, 0) as GeoJSON.Feature[] this.clusterLeaves.set(id, leaves) } } @@ -365,7 +365,7 @@ export class TeritorioCluster extends EventTarget { let marker = this.markersOnScreen.get(id) const props = feature.properties - if (props.cluster) { + if (props?.cluster) { const leaves = this.clusterLeaves.get(id) if (!leaves) { diff --git a/src/utils/helpers.ts b/src/utils/helpers.ts index 9dfe015..a9a1672 100644 --- a/src/utils/helpers.ts +++ b/src/utils/helpers.ts @@ -1,4 +1,4 @@ -import type { LngLatLike, MapGeoJSONFeature } from 'maplibre-gl' +import type { LngLatLike } from 'maplibre-gl' import { Marker, Point } from 'maplibre-gl' // Helper to apply styles on DOM element @@ -12,10 +12,10 @@ export const buildCss = (htmlEl: HTMLElement, styles: { [key: string]: string }) // Circle shape export const unfoldedClusterRenderCircle = ( parent: HTMLDivElement, - items: MapGeoJSONFeature[], + items: GeoJSON.Feature[], markerSize: number, - renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, - clickHandler: (e: Event, feature: MapGeoJSONFeature) => void + renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, + clickHandler: (e: Event, feature: GeoJSON.Feature) => void ): void => { const radius = (markerSize / 2) / Math.sin(Math.PI / items.length) let angle = 360 / items.length @@ -47,10 +47,10 @@ export const unfoldedClusterRenderCircle = ( // HexaGrid shape export const unfoldedClusterRenderHexaGrid = ( parent: HTMLDivElement, - items: MapGeoJSONFeature[], + items: GeoJSON.Feature[], markerSize: number, - renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, - clickHandler: (e: Event, feature: MapGeoJSONFeature) => void + renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, + clickHandler: (e: Event, feature: GeoJSON.Feature) => void ): void => { const radius = (markerSize / 2) / Math.sin(Math.PI / items.length) @@ -96,10 +96,10 @@ export const unfoldedClusterRenderHexaGrid = ( // Smart: mix between Circle and HexaGrid shape export const unfoldedClusterRenderSmart = ( parent: HTMLDivElement, - items: MapGeoJSONFeature[], + items: GeoJSON.Feature[], markerSize: number, - renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, - clickHandler: (e: Event, feature: MapGeoJSONFeature) => void + renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, + clickHandler: (e: Event, feature: GeoJSON.Feature) => void ): void => { if (items.length <= 5) { unfoldedClusterRenderCircle(parent, items, markerSize, renderMarker, clickHandler) @@ -111,10 +111,10 @@ export const unfoldedClusterRenderSmart = ( // Grid shape export const unfoldedClusterRenderGrid = ( parent: HTMLDivElement, - items: MapGeoJSONFeature[], + items: GeoJSON.Feature[], _markerSize: number, - renderMarker: (feature: MapGeoJSONFeature) => HTMLDivElement, - clickHandler: (e: Event, feature: MapGeoJSONFeature) => void + renderMarker: (feature: GeoJSON.Feature) => HTMLDivElement, + clickHandler: (e: Event, feature: GeoJSON.Feature) => void ): void => { buildCss(parent, { 'display': 'flex', @@ -136,7 +136,7 @@ export const unfoldedClusterRenderGrid = ( // Cluster default styles export const clusterRenderDefault = ( element: HTMLDivElement, - props: MapGeoJSONFeature['properties'] + props: NonNullable ): void => { element.innerHTML = props.point_count diff --git a/tests/mocks/maplibre-gl.mock.ts b/tests/mocks/maplibre-gl.mock.ts index 80e0805..1a57424 100644 --- a/tests/mocks/maplibre-gl.mock.ts +++ b/tests/mocks/maplibre-gl.mock.ts @@ -9,6 +9,15 @@ vi.mock('maplibre-gl', () => { getClusterLeaves: vi.fn(), }), fitBounds: vi.fn(), - })) + })), + Marker: vi.fn().mockImplementation(() => ({ + setLngLat: vi.fn().mockReturnThis(), + addTo: vi.fn().mockReturnThis(), + setOffset: vi.fn().mockReturnThis(), + })), + Point: vi.fn().mockImplementation((x, y) => ({ + x, + y, + })), } }) diff --git a/tests/set-selected-feature.test.ts b/tests/set-selected-feature.test.ts new file mode 100644 index 0000000..3f3a493 --- /dev/null +++ b/tests/set-selected-feature.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { TeritorioCluster } from '../src/teritorio-cluster' +import { Map as MapGL } from 'maplibre-gl' +import { getFeatureId } from '../src/utils/get-feature-id'; + +vi.mock('../src/utils/get-feature-id', () => ({ + getFeatureId: vi.fn(), +})); + +describe('setSelectedFeature', () => { + let map: MapGL + let teritorioCluster: TeritorioCluster + + beforeEach(() => { + map = new MapGL({ container: 'map'}) + teritorioCluster = new TeritorioCluster(map, 'sourceId') + }) + + it('should render pin marker when feature is not found and is a Point', () => { + const feature = { + type: 'Feature', + geometry: { type: 'Point', coordinates: [10, 20] }, + properties: null + } satisfies GeoJSON.Feature + + vi.mocked(getFeatureId).mockReturnValue('some-unique-id') + + teritorioCluster.setSelectedFeature(feature); + + expect(teritorioCluster.selectedFeatureId).toBe('some-unique-id'); + expect(teritorioCluster.pinMarker?.setLngLat).toHaveBeenCalledWith([10, 20]); + expect(teritorioCluster.pinMarker?.addTo).toHaveBeenCalledWith(map); + }); + + it('should log an error if feature is not a Point and not found', () => { + const feature = { + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[]]] }, + properties: null + } satisfies GeoJSON.Feature + const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + vi.mocked(getFeatureId).mockReturnValue('some-unique-id') + + teritorioCluster.setSelectedFeature(feature); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Feature some-unique-id is not of type \'Point\', and is not supported.'); + }); +}) \ No newline at end of file