אלול

הרפתקאות מפת בתי הספר באתר אורט – פרק ד’, המפה: סנכרון בין המפה לרשימת הערים

הפוסט הזה הוא חלק מסדרה על הקוד של עמוד בתי הספר של אורט באתר אורט החדש, עמוד שמכיל רשימה של כל בתי הספר של אורט לפי ערים, ומפה עם סימוני כל בתי הספר.
בפרק ההקדמה סיפרתי על הרקע הכללי של האתר ומבנה הסדרה, בפרק הראשון סיפרתי על סינון הערים לפי מחרוזת חיפוש, בפרק השני דיברנו על אתגרי המפה ואיך מתחילים איתה, בפרק השלישי הכנו את התשתית לסנכרון הרשימה והמפה, ובפרק הזה נצלול לסנכרון.

אתגר הסנכרון

בפרק הזה של הרפתקאותיי במפות גוגל, נדבר על הסנכרון בין המפה לרשימת הערים. כמו שסיפרתי בפרק המבוא, אחד השדרוגים בגרסה הזאת של אתר אורט היא סנכרון בין המפה לרשימת הערים ולהפך. כלומר, כשגולש לוחץ על שם של עיר, המיקוד של המפה יהיה בעיר (ולפעמים בסביבותיה), ומנגד, כשגולש ממקד (zoom) או גורר את המפה, רשימת הערים מצטמצמת כך שמוצגות רק אלה שנראות במפה בעקבות הפעולה.

ההיבט הזה היה מאתגר. בכל חיפושיי הרבים לא הצלחתי למצוא מידע על אירועים של מפה שאפשר להקשיב להם ולפעול כשהם קורים, וגם לא מצאתי את ההפך – איך להעביר למפה, דרך הקוד, הוראות לשינוי המיקוד. אבל לא אלמן ישראל – גם לאתגר הזה מצאתי פתרון בספר שמנהל היחידה שלי שלח לי: יש בו פרק שמדבר על סנכרון בין שתי מפות, והקונספטים שהוא העלה שם התאימו למקרה שלנו.

רשימת הערים מצטמצמת בהתאם לזום של המפה

זום במפה כטריגר לעדכון רשימת הערים המוצגות

האירוע שיש להקשיב לו כדי לדעת על שינוי מיקוד במפה, הוא bounds_changed ואת ההקשבה לה הכנסתי לפונקציה initMap שדיברנו עליה בפרק ב’.

        /* When the map bounds are changed, change the city list to reflect the new bounds */
        google.maps.event.addListener(map, 'bounds_changed', boundsChangedHandler);

כמו שאפשר לראות, האירוע של bounds_changed מפעיל את הפונקציה boundsChangedHandler. מה היא עושה?

אז קודם כל, כדי שבכלל יהיה לי מה לעשות עם האירוע, אני צריכה לדעת מה הקואורדינטות של המפה לאחר האירוע, ואילו ערים נמצאות בקואורדינטות האלה. את הקואורדינטות מוצאים די בקלות עם map.getBounds(). ואיך נדע אילו ערים יש שם? לשם כך יצרנו את קובץ הערים שדיברנו עליו בפרק הקודם – הוא מכיל את כל הערים שקיימות באקורדיון, עם כל המידע שגוגל מביא עליהן.

אובייקט ה-Geocoder של גוגל מביא 3 סוגים של מידע, ברמות שונות של דיוק: location, bounds, ו-viewport.

{
  "name": "בית שמש",
  "location": {
    "lat": 31.747041,
    "lng": 34.988099000000034
  },
  "zoom": 8,
  "bounds": {
    "south": 31.68703,
    "west": 34.94659909999996,
    "north": 31.768437,
    "east": 35.032696999999985
  },
  "southWestViewport": {
    "lat": 31.68703,
    "lng": 34.94659909999996
  },
  "northEastViewport": {
    "lat": 31.768437,
    "lng": 35.032696999999985
  }
}

ה-viewport הוא המידע המדוייק ביותר, אחריו מבחינת רמת הדיוק נמצאה-bounds, ואחרון, זה שנשתמש בו רק אם השניים האחרים חסרים, הוא ה-location, והוא מצריך שימוש גם ב-zoom. אנחנו נשתמש במידע הזה בפונקציה applyCityClickToMap שמופיעה בהמשך הפוסט.

השלב הבא היה לבדוק אילו מהערים בקובץ נמצאות במפה, והשלב אחרי זה הוא להסתיר את אלה שאינן מופיעות. איך נבדוק את הקיום? בהתחלה חשבתי שאצטרך לעבור עיר-עיר ולהשוות אחת-אחת את הקואורדינטות שלה לאלה של המפה. התמלאתי ייאוש רק מהמחשבה על הקוד המרנין הזה, כשפתאום התברר לי שישנה פוקנציה בשם contains(). היא שייכת לאובייקת שיצרנו עם map.getBounds() שהזכרתי קודם, והיא פשוט קוסמת: מעבירים לה את המאפיין location של עיר, והיא מחזירה אם ה-location הזה נמצא בתחום של המפה. קסם!!

אבל רק כמעט.
התברר שאפילו לפונקציה הקסומה הזאת יש באג קטן, אך התגברתי עליו: כשהזום היה גדול מאוד, הקואורדינטות של העיר כמובן לא היו בתחום של המפה, ואז העיר לא הייתה מופיעה ברשימת הערים למרות שהמפה הייתה בדיוק ממוקדת עליה! לכן נאלצתי לכתוב קוד שבודק גם את ההפך: במקרה שהעיר לא נמצאת בתחום של המפה, לבדוק אם הקואורדינטות של המפה נמצאות בתוך התחום של העיר. רק אם שני התנאים האלה לא התקיימו, העיר הוסרה מהרשימה.

את כל החישוב המרנין הזה הוצאתי לפונקציה נפרדת בשם  getCitiesinMap והיא מופיעה מיד אחרי boundsChangedHandler.


    /**
     * When the map's bounds change (by zoom or by dragging the map),
     * filter the city list to only the cities that appear in the map
     * This is fired right on the map load because of the kmlLayer added.
     */
    function boundsChangedHandler() {
        /* Sometimes the kml layer comes late and the bounds change while the user is filtering.
        * Since the bounds chage affect the city list and so does the filtering, we should make sure
        * the application of the changed bounds shouldn't happen if the user is in the middle of filtering. */
        if (!fieldSearchSchools.is(':focus')) {
            // get new bounds
            let mapBounds = map.getBounds();
            if (mapBounds !== null) {
                $.getJSON(`${schools_and_map_filter_ajax_obj.json_file}?ver=${Date.now()}`, (data) => {
                    let res = getCitiesinMap(data, mapBounds);

                    // filter out cities that don't appear in bounds
                    cityNameLinkElements.each((key, value) => {
                        let elementorAccordionItem = $(value).parents('.elementor-accordion-item');
                        // if the innerHTML, which is the city name, doesn't exist in the list of filtered cities, hide it
                        let cityExists = res.filter(item => item.name.trim() === value.innerHTML.trim());
                        // if the city doesn't appear in bounds, hide it
                        if (cityExists.length === 0) {
                            // hide it
                            elementorAccordionItem.hide();
                        }
                        // if the city appears - make it show (needed on zoom out, to return the cities previously hidden)
                        else {
                            /* The value is the cityNameLinkElement
                            * if the city exists, turn on its parent and its siblings which are the schools */
                            elementorAccordionItem.show();
                            elementorAccordionItem.find('.elementor-tab-content a').show();
                        }
                    });
                });
            }
            mapIsReset = false;
        }
    }

הנה הפונקציה getCitiesinMap וחישוביה, עם הפונקציה הקסומה contains().

    /**
     * Get an array of cities that appear in the map after its bounds have changed
     * @param cityFilecontents - the cities that we stored in our json file, including their geocode data
     * @param mapBounds - the bounds of the map after change
     * @returns the city that appears in the map, or nothing of city doesn't appear in the map
     */
    function getCitiesinMap(cityFilecontents, mapBounds) {
        return cityFilecontents.filter(city => {
            // first check if the map contains this city's location
            let mapContainsCity = mapBounds.contains(city.location);
            /* if not, it could be because of very large zoom, that excludes this city's bounds.
             * In this case we want to check if the center of the map is in this city's bounds */
            if (!mapContainsCity) {
                // create a bounds object out of this city's bounds
                let cityBounds = new google.maps.LatLngBounds(
                    city.southWestViewport,
                    city.northEastViewport
                );
                // check if the city bounds contain this map's center
                mapContainsCity = cityBounds.contains(map.getCenter());
            }
            if (mapContainsCity) {
                return city;
            }
        });
    }

לחיצה על עיר כטריגר לשינוי הזום במפה

כדי שלחיצה על עיר תגרום לשינוי הזום במפה, צריך לדעת את הקואורדינטות של העיר. כמו שסיפרתי בפרק הקודם, יש לגוגל API של Geocode שמקבל שם של עיר ומחזיר את כל המידע הגאוגרפי על העיר, אבל מסיבות שפירטתי שם, אנחנו אוגרים את המידע בקובץ JSON ומשתמשים בו בעת לחיצה על שם של עיר במקום להשתמש ב-API.

***

וכעת אפשר להבין את הפונקציות שמופעלות כשלוחצים על שם של עיר:

קודם כל קשירת האירוע של לחיצה על שם של עיר, לפעילות שמתרחשת בעקבותיה:

cityNameElements.on('click', e => {
    if (mapStaticImage.is(":visible")) {
        mapStaticImage.hide(mediumSpeed);
        initMap();
    }
    cityClickedHandler($(e.target));
});

כרגע נתעלם מהתנאי של תמונה סטטית מפני שדבר על זה בפרק ה’1. נתמקד בפונקציה cityClickHandler:

כשלוחצים על עיר ברשימה יש שתי אפשרויות: או שהעיר נפתחת, או שהיא נסגרת. איך נזהה מה קורה? ראיתי שכשעיר נפתחת, אלמנטור מוסיף לה את הקלאס “elementor-active”, ורציתי להשתמש בה, אבל זה לא הסתייע, וטוב שכך: זה לא הסתייע כי מתברר שהקוד שלי רץ לפני שאלמנטור הספיק לתת את הקלאס, וטוב שכך מפני שלא כדאי להסתמך על קלאס שניתן על ידי קוד שהוא לא שלי. לכן הפתרון שלי היה לתת קלאס משלי.

המקרה היחיד שבו כן השתמשתי בקלאס של אלמנטור הוא כשלוחצים על הפריט הראשון ברשימה. כי אקורדיון של אלמנטור מגיע עם הפריט הראשון פתוח, ולכן האפקט של הלחיצה עליו היא הפוכה מהאפקט של לחיצה על כל שאר הפריטים.

לאלמנט של שם העיר מתווסף class בשם elementor-active כשהעיר נפתחת

    /**
     * This function is run when a city is clicked and it syncs city click with map, i.e the map zooms in on that city.
     * It looks up the city in our JSON file (cities_map.json, in the JS folder of this plugin),
     * returns its coordinates, and those coordinates are assigned to the center of the map.
     */
    function cityClickedHandler(self) {

        let accordionItem = self.parents('.elementor-accordion-item');

        /* If city is open but doesn't have a cityOpen class,
        identify it by the elementor-active class that its elementor-tab-content has.
         This happens when one of cities is displayed open by default when the accordion loads */
        if (accordionItem.children('.elementor-active').length > 0) {
            accordionItem.addClass('cityOpen');
        }

        if (accordionItem.siblings().hasClass('cityOpen')) {
            accordionItem.siblings().removeClass('cityOpen');
        }
        // toggle class so we know if we're opening or closing a city
        accordionItem.toggleClass('cityOpen');
        /* If the click was to open city, change the map bounds.
         * Else the click is closing the city, and the bpunds should return to original */
        if (accordionItem.hasClass('cityOpen')) {
            /* if we clicked after search, some of the schools might be hidden.
            * If the map is reset that means we're in filter mode, and we want to show only the filtered schools.
            * But if the map is focused then we're in regular mode and we want to show all schools */
            if (!mapIsReset) {
                accordionItem.find('li').show();
            }
            // this is the link that was clicked, and its inner HTML contains the city name
            let address = accordionItem.find('.elementor-tab-title a').html();
            // read fron the JSON file. Added the Date.now() to the version, so the file is always fresh during development.
            // TODO: remove version when upload to production
            $.getJSON(`${schools_and_map_filter_ajax_obj.json_file}?ver=${Date.now()}`, (data) => {
                // check if the current clicked city is in the file
                let res = data.filter(item => item.name === address);
                // if it's in the file, sync the map to zoom in on that city
                if (res.length > 0) {
                    applyCityClickToMap(res[0]);
                }
            });
        } else {
            // when the city is clicked closed, resume the original bounds and zoom
            resetMap();
        }
    }

ואחריה הפונקציה applyCityClickToMap, שמשנה את גבולות המפה בהתאם לעיר שנבחרה:

/**
 * Change the zoom and bounds of the map according to the location of the city that was clicked.
 * The method attempts to first use the southWestViewport and northEastViewport properties because they are the most accurate.
 * If they don't exist, it uses the bounds property.
 * If the doesn't exist, it uses the location and zoom properties which are the least accurate measures.
 * @param result - the result object. It has the location, zoom, bounds, southWestViewport, and northEastViewport properties.
 */
function applyCityClickToMap(result) {
    /*  code taken from: https://stackoverflow.com/questions/9491114/google-maps-api-v3-geocoder-results-issue-with-bounds */
    if (result.southWestViewport && result.northEastViewport) {
        var resultBounds = new google.maps.LatLngBounds(
            result.southWestViewport,
            result.northEastViewport
        );
        map.fitBounds(resultBounds);
    } else {
        if (result.bounds) {
            map.fitBounds(result.bounds);
        } else {
            map.setCenter(result.location);
            map.setZoom(result.zoom);
        }
    }
}

כשלחיצה על שם העיר סוגרת אותה, מחזירים את המפה למצבה הראשוני:

/**
 * Reset the map to the kmlLayer
 */
function resetMap() {
    if (typeof kmlLayer !== 'undefined') {
        kmlLayer.setMap(map);
        mapIsReset = true;
    }
}

עדכון יולי 2020: אחרי שפתאום שכבת ה-kml לא עלתה מסיבות שפירטתי בפרק ב’ וההעדרות של השכבה גרמה לחוסר תפקוד של המפה, כתבנו קוד שמעלה את המפה גם בלי השכבה. כך שלמעשה יש שתי אפשרויות לאתחל את המפה – אחת עם השכבה ואחת בלעדיה:

/**
 * Reset the map, either using the kmlLayer or only using the ccordinates if the kml layer doesn't load
 */
function resetMap() {
    if (KmlLayerExists) {
        kmlLayer.setMap(map);
        zoomValue = 8;
    } else {
        // Set the map without using the kmlLayer, because when kmlLayer doesn't exist, the map setting doesn't work
        map.setCenter(new google.maps.LatLng(centerLat, centerLong));
    }
    map.setZoom(zoomValue);
    mapIsReset = true;
}

וזה, רבותיי, כל מה שהיה צריך לעשות כדי שיהיה קשר בין המפה לרשימת הערים. בפרק הבא ניפרד מרשימת הערים ונתמקד במפה. היו עימנו.

כתבו תגובה

כתובת הדוא"ל שלכם לא תוצג.