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

add mobile gesture handling for pitch #3405

Closed
incanus opened this issue Oct 18, 2016 · 16 comments
Closed

add mobile gesture handling for pitch #3405

incanus opened this issue Oct 18, 2016 · 16 comments
Labels
feature 🍏 GL native → GL JS For feature parity with Mapbox Maps SDK on a native platform

Comments

@incanus
Copy link
Contributor

incanus commented Oct 18, 2016

We need gesture support on mobile web to allow the map to be pitched. The way this works gesturally on native mobile is two fingers swiped up or down, both in parallel with each other and approximately the same y-value.

Without this, there isn't a way to pitch on mobile without an external control button or similar that toggles between pitch values a là View elevation profile in https://www.mapbox.com/blog/dc-bikeshare-revisited/.

Related: #2449

@incanus incanus added feature 🍏 perspective GL native → GL JS For feature parity with Mapbox Maps SDK on a native platform labels Oct 18, 2016
@1ec5
Copy link
Contributor

1ec5 commented Oct 19, 2016

This is a special case of #2618. As far as I can tell, gesture event support exists in mobile browsers and Internet Explorer, Edge, and Safari on the desktop. Blink and Gecko appear to lack gesture event support, but given the importance of mobile Web browsing, I don’t think that should matter.

@ansarikhurshid786
Copy link

Any update or work around?

@bFlood
Copy link

bFlood commented Dec 18, 2018

bump - Any update or work around?

@bFlood
Copy link

bFlood commented Dec 20, 2018

here's some quick and dirty code just using simple map events, not perfect but it gets the job done until something similar is added to the touch_zoom_rotate handler

demo here: http://bit.ly/2SY7p2S

this.map.on('touchstart', function(data) {
   if (data.points.length == 2) {
      var diff = Math.abs(data.points[0].y - data.points[1].y);
      if (diff <= 50) {
         data.originalEvent.preventDefault();   //prevent browser refresh on pull down
         self.map.touchZoomRotate.disable();    //disable native touch controls
         self.map.dragPan.disable();
         self.dpPoint = data.point;
         self.dpPitch = self.map.getPitch();
      }
   }
});

this.map.on('touchmove', function(data) {
   if (self.dpPoint) {
      data.preventDefault(); 
      data.originalEvent.preventDefault();
      var diff = (self.dpPoint.y - data.point.y) * 0.5;
      self.map.setPitch(self.dpPitch + diff);
   }
});

this.map.on('touchend', function(data) {
    if (self.dpPoint){
      self.map.touchZoomRotate.enable();
      self.map.dragPan.enable();
   }
   self.dpPoint = null;
});

this.map.on('touchcancel', function(data) {   
   if (self.dpPoint){
      self.map.touchZoomRotate.enable();
      self.map.dragPan.enable();
   }
   self.dpPoint = null;
}); 

@ansarikhurshid786
Copy link

@bFlood Good job. it's working as expected.

@cs09g
Copy link
Contributor

cs09g commented Jan 4, 2019

beyond @bFlood 's solution would be checking distance between two points instead y-distance.
@bFlood 's solution disables rotation when two fingers start rotating from horizontal positions.

...
var diff = Math.abs(data.points[0].y - data.points[1].y);
if (diff <= 50) {
...

to

...
const diffY = data.points[0].y - data.points[1].y;
const diffX = data.points[0].x - data.points[1].x;
const distance = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2));
if (distance <= 120) {
...

@fishead
Copy link

fishead commented Jan 10, 2019

@cs09g Good job. How about remove the alert(distance); ?

const distance = Math.sqrt(Math.pow(diffX, 2) + Math.pow(diffY, 2)); alert(distance);

@fishead
Copy link

fishead commented Jun 6, 2019

yet another solution base on @bFlood one.

function addMobilePitchGesture(mbMap) {
  const minDiffX = 70; // min x distance to recognize pitch gesture
  const maxDiffY = 100; // max y distance to recognize pitch gesture
  const minDiff = 30; // min distance to recognize zoom gesture
  const delay = 160; // delay for pitch, in case it's a zoom gesture

  let dpPoint;
  let dpPitch;
  let startTiming;
  let startDistance;
  let startEventData;

  mbMap
    .on("touchstart", data => {
      if (data.points.length === 2) {
        const diffY = data.points[0].y - data.points[1].y;
        const diffX = data.points[0].x - data.points[1].x;
        if (Math.abs(diffX) >= minDiffX && Math.abs(diffY) <= maxDiffY) {
          data.originalEvent.preventDefault(); // prevent browser refresh on pull down
          mbMap.touchZoomRotate.disable(); // disable native touch controls
          mbMap.dragPan.disable();
          dpPoint = data.point;
          dpPitch = mbMap.getPitch();
          startTiming = Date.now();
          startDistance = Math.hypot(diffX, diffY);
          startEventData = data;
        }
      }
    })
    .on("touchmove", data => {
      if (dpPoint !== undefined && dpPitch !== undefined) {
        data.preventDefault();
        data.originalEvent.preventDefault();

        const diffY = data.points[0].y - data.points[1].y;
        const diffX = data.points[0].x - data.points[1].x;
        const distance = Math.hypot(diffX, diffY);

        if (Math.abs(distance - startDistance) >= minDiff) {
          if (dpPoint) {
            mbMap.touchZoomRotate.enable();
            mbMap.dragPan.enable();
            mbMap.touchZoomRotate.onStart(
              Date.now() - startTiming >= delay
                ? data.originalEvent
                : startEventData.originalEvent
            );
          }
          dpPoint = undefined;
          return;
        }

        if (Date.now() - startTiming >= delay) {
          const diff = (dpPoint.y - data.point.y) * 0.5;
          mbMap.setPitch(dpPitch + diff);
        }
      }
    })
    .on("touchend", () => {
      if (dpPoint) {
        mbMap.touchZoomRotate.enable();
        mbMap.dragPan.enable();
      }
      dpPoint = undefined;
    })
    .on("touchcancel", () => {
      if (dpPoint) {
        mbMap.touchZoomRotate.enable();
        mbMap.dragPan.enable();
      }
      dpPoint = undefined;
    });
}

@HarelM
Copy link

HarelM commented Jun 13, 2019

Any updates on this? It basically means that someone that uses a mobile device can't see 3D buildings without this custom code... This is a must...

@HarelM
Copy link

HarelM commented Jun 14, 2019

Yet another option - this time in typescript based on @fishead code. the only difference is that if you try to rotate the map when both fingers are on the "same" y position it will rotate if they are enough apart (MAX_DIFF_X below).

import { Map, MapTouchEvent, Point } from "mapbox-gl";

export class TouchPitchHandler {
    /**
     * min x distance to recognize pitch gesture
     */
    private static readonly MIN_DIFF_X = 55;
    /**
     * max x distance to recognize pitch gesture - 
     * this is in order to allow rotate when the fingers are spread apart enough 
     * and both have the "same" y value
     */
    private static readonly MAX_DIFF_X = 200;
    /**
     *  max y distance to recognize pitch gesture
     */
    private static readonly MAX_DIFF_Y = 100;
    /**
     * min distance threshold the fingers drifted from the original touch - 
     * this is in order to recognize zoom gesture
     */
    private static readonly MIN_DIFF = 30;
    /** 
     * delay for pitch, in case it's a zoom gesture
     */
    private static readonly DELAY = 160;

    private startEventData: MapTouchEvent;
    private point: Point;
    private pitch: number;
    private startTiming: number;
    private startDistance: number;

    constructor(private readonly map: Map) {
        this.startEventData = null;
        this.point = null;
        this.pitch = null;
        this.startDistance = null;
        this.startTiming = null;
    }

    public enable() {
        this.map.on("touchstart", (touchEvent: MapTouchEvent) => {
            this.handleTouchStart(touchEvent);
        });
        this.map.on("touchmove", (touchEvent: MapTouchEvent) => {
            this.handleTouchMove(touchEvent);
        });
        this.map.on("touchend", () => {
            this.resetInteractions();
        })
        this.map.on("touchcancel", () => {
            this.resetInteractions();
        });
    }

    private handleTouchStart(touchEvent: MapTouchEvent) {
        if (touchEvent.points.length !== 2) {
            return;
        }
        const diffY = touchEvent.points[0].y - touchEvent.points[1].y;
        const diffX = touchEvent.points[0].x - touchEvent.points[1].x;
        if (Math.abs(diffX) < TouchPitchInteraction.MIN_DIFF_X
            || Math.abs(diffY) > TouchPitchInteraction.MAX_DIFF_Y
            || Math.abs(diffX) > TouchPitchInteraction.MAX_DIFF_X) {
            return;
        }

        touchEvent.originalEvent.preventDefault(); // prevent browser refresh on pull down
        this.map.touchZoomRotate.disable(); // disable native touch controls
        this.map.dragPan.disable();
        this.point = touchEvent.point;
        this.pitch = this.map.getPitch();
        this.startTiming = Date.now();
        this.startDistance = Math.hypot(diffX, diffY);
        this.startEventData = touchEvent;
    }

    private handleTouchMove(touchEvent: MapTouchEvent) {
        if (this.point == null || this.pitch === null) {
            return;
        }
        touchEvent.preventDefault();
        touchEvent.originalEvent.preventDefault();

        const diffY = touchEvent.points[0].y - touchEvent.points[1].y;
        const diffX = touchEvent.points[0].x - touchEvent.points[1].x;
        const distance = Math.hypot(diffX, diffY);

        let isTimePassed = Date.now() - this.startTiming >= TouchPitchInteraction.DELAY;
        if (Math.abs(distance - this.startDistance) >= TouchPitchInteraction.MIN_DIFF) {
            let eventData = isTimePassed ? touchEvent.originalEvent : this.startEventData.originalEvent;
            this.resetInteractions();
            (this.map.touchZoomRotate as any).onStart(eventData);
            return;
        }

        if (isTimePassed) {
            const diff = (this.point.y - touchEvent.point.y) * 0.5;
            this.map.setPitch(this.pitch + diff);
        }
    }

    private resetInteractions() {
        if (this.point) {
            this.map.touchZoomRotate.enable();
            this.map.dragPan.enable();
        }
        this.point = null;
    }
}

@vakila vakila self-assigned this Aug 19, 2019
@ansarikhurshid786
Copy link

any update ?

@ansis
Copy link
Contributor

ansis commented Apr 7, 2020

Thanks to everyone that has provided workarounds for this!

A two-finger-drag-to-pitch gesture has been implemented by #9365. If you have a chance to test and confirm, that would be great! The fix is in master and will be released in a beta release tomorrow.

@karimnaaji
Copy link
Contributor

Hello, just a heads up about the beta, it was published yesterday, feel free to give it a try and provide some feedback! https://www.npmjs.com/package/mapbox-gl/v/1.10.0-beta.1

@HarelM
Copy link

HarelM commented Apr 14, 2020

I've used the relevant code since I need a patched version and it seems to be working great! I was able to remove the code I wrote for the same purpose, thanks!!

@HarelM
Copy link

HarelM commented Apr 18, 2020

@karimnaaji I seem to be experiencing a bug in touchend event - the latLng value of the event is to the top left corner instead of where the touchend occurred.
Are you guys familiar with this issue?
I'll update this comment if I'll be able to create a small reproduction.
I can't reproduce on beta version, probably related to #9536

@ansis
Copy link
Contributor

ansis commented Apr 21, 2020

@HarelM yep, #9536 fixed that! Thanks for testing

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
feature 🍏 GL native → GL JS For feature parity with Mapbox Maps SDK on a native platform
Projects
None yet
Development

No branches or pull requests