Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Add a Spiderfier #216

Open
Johjoh-6 opened this issue Nov 22, 2024 · 0 comments
Open

[FEATURE] Add a Spiderfier #216

Johjoh-6 opened this issue Nov 22, 2024 · 0 comments

Comments

@Johjoh-6
Copy link

A few months ago triedry to adapte an old plugging from Leaflet call Spiderfier.

This is the result.

import type { Map as M, Marker } from 'maplibre-gl';
import mapLibre from 'maplibre-gl';

interface SpiderLegParam {
	x: number;
	y: number;
	angle: number;
	legLength: number;
	index: number;
}

export interface SpiderLeg {
	feature: GeoJSON.Feature;
	maplibreMarker: Marker;
	param: SpiderLegParam;
}

export interface OptionsSpiderfier {
	customPin: boolean;
	onClick?: (e: Event, spiderLeg: SpiderLeg) => void;
	circleSpiralSwitchover: number;
	circleFootSeparation: number;
	spiralFootSeparation: number;
	spiralLengthStart: number;
	spiralLengthFactor: number;
	makerClassName: string;
	popUpClassName: string;
}

export default class Spiderfier {
	private map: M;
	private options: OptionsSpiderfier;
	private previousSpiderLegs: SpiderLeg[] = [];
	private spiderLeg: SpiderLeg | null = null;

	constructor(map: M, userOptions: Partial<OptionsSpiderfier>) {
		this.map = map;
		this.options = {
			customPin: false,
			circleSpiralSwitchover: 10,
			circleFootSeparation: 20,
			spiralFootSeparation: 30,
			spiralLengthStart: 15,
			spiralLengthFactor: 4,
			makerClassName: 'spider-marker',
			popUpClassName: 'spider-popup',
			...userOptions
		};
	}

	public spiderfy(
		latLng: [number, number],
		features: GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>[],
		currentZoom: number
	) {
		const spiderLegs = this.generateSpiderLegs(latLng, features, currentZoom);

		this.previousSpiderLegs = spiderLegs;
	}

	public unspiderfy() {
		for (const spiderLeg of this.previousSpiderLegs) {
			spiderLeg.maplibreMarker.remove();
		}

		this.previousSpiderLegs = [];
	}

	private generateSpiderLegs(
		latLng: [number, number],
		features: GeoJSON.Feature<GeoJSON.Geometry, GeoJSON.GeoJsonProperties>[],
		zoom: number
	): SpiderLeg[] {
		const spiderLegParams = this.generateSpiderLegParams(features.length);

		return features.map((feature, index) => {
			const spiderLegParam = spiderLegParams[index];
			const maplibreMarker = this.createMaplibreMarker(latLng, spiderLegParam, zoom);
			const spiderLeg: SpiderLeg = {
				feature,
				maplibreMarker,
				param: spiderLegParam
			};

			this.initializeSpiderLeg(spiderLeg);

			return spiderLeg;
		});
	}

	private initializeSpiderLeg(spiderLeg: SpiderLeg) {
		if (this.options.onClick && typeof this.options.onClick === 'function') {
			spiderLeg.maplibreMarker.getElement()?.addEventListener('click', (e) => {
				this.options.onClick?.(e, spiderLeg);
				this.spiderLeg = spiderLeg;
			});
		}
	}

	private generateSpiderLegParams(count: number): SpiderLegParam[] {
		return count >= this.options.circleSpiralSwitchover
			? this.generateSpiralParams(count)
			: this.generateCircleParams(count);
	}

	private generateSpiralParams(count: number): SpiderLegParam[] {
		let legLength = this.options.spiralLengthStart;
		let angle = 0;

		return Array.from({ length: count }, (_, index) => {
			angle += this.options.spiralFootSeparation / legLength + index * 0.0005;
			legLength += (2 * Math.PI * this.options.spiralLengthFactor) / angle;

			return {
				x: legLength * Math.cos(angle),
				y: legLength * Math.sin(angle),
				angle,
				legLength,
				index
			};
		});
	}

	private generateCircleParams(count: number): SpiderLegParam[] {
		const circumference = this.options.circleFootSeparation * (2 + count);
		const legLength = circumference / (2 * Math.PI); // = radius from circumference
		const angleStep = (2 * Math.PI) / count;

		return Array.from({ length: count }, (_, index) => {
			const angle = index * angleStep;
			return {
				x: legLength * Math.cos(angle),
				y: legLength * Math.sin(angle),
				angle,
				legLength,
				index
			};
		});
	}

	private createMaplibreMarker(
		latLng: [number, number],
		spiderLegParam: SpiderLegParam,
		zoom: number
	): Marker {
		const minScaleFactor = 0.00002; // min scale factor for the spider legs
		const maxScaleFactor = 0.0001; // max scale factor for the spider legs

		const minZoom = 12; // min zoom level for the spider legs
		const maxZoom = 15; // max zoom level for the spider legs

		// Ensure zoom is within the valid range
		const clampedZoom = Math.max(minZoom, Math.min(maxZoom, zoom));

		// Calculate the scale factor using a reverse linear interpolation
		const scale = Math.max(
			minScaleFactor,
			maxScaleFactor -
				((maxScaleFactor - minScaleFactor) * (clampedZoom - minZoom)) / (maxZoom - minZoom)
		);

		const newLatLng = new mapLibre.LngLat(
			latLng[0] + spiderLegParam.x * scale,
			latLng[1] + spiderLegParam.y * scale
		);

		const marker = new mapLibre.Marker({
			color: '#ff0000',
			className: this.options.makerClassName
		}).setLngLat(newLatLng);
		marker.addTo(this.map); // Add the marker to the map
		return marker;
	}
}

I know is not the best. But it works well for my use case.

Can we consider adding this kind of feature, I am not the only one to use this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant