#Oracle APEX GoogleMaps FacetedSearch
Explore tagged Tumblr posts
brian-hill · 5 years ago
Text
Oracle APEX: Making Google Maps respond to Faceted Search
“One of my work projects elicited the need to have a map that responded to changing search criteria.  I thought it would be a great opportunity to mix APEX’s new Faceted Search feature with Google Maps.  I’ll try to walk you through how I accomplished it.  [Disclaimer: you’ll need an API Key from Google in order to render the maps.]
The APEX Faceted Search feature was introduced with version 19.2.  Out-of-the-box, you can have a region for your search criteria (i.e. facets) and another region, a report, that refreshes automatically to reflect the new search filters.  Carsten Czarski wrote a blog post showing how you can have multiple regions (charts, reports, etc.) react to changes to the faceted search.  I used his approach, with some tweaks, in combination with the Google Maps JavaScript APIs.
The simplified (silly) use-case here is a list of NCAA (FBS) college football stadiums.  With the faceted search, you can filter a report region by conference, state, and seating capacity.  There is also a Google Map with a marker for each matching stadium.  You can try it out here:
https://apex.oracle.com/pls/apex/bhill_roadling/r/blog3/blog-stadiums-search
I am limiting the number of times the Google Map can be rendered because there is a cost associated with it.  Here are some screen shots in case we’ve hit our quota or the page doesn’t work for some other reason:
Tumblr media Tumblr media
First, you’ll need a data set - I got mine from here.  I loaded it into a table called BLOG_STADIUMS, which is defined as: 
CREATE TABLE  "BLOG_STADIUMS"   ( "ID" NUMBER GENERATED ALWAYS AS IDENTITY MINVALUE 1 MAXVALUE 9999999999999999999999999999 INCREMENT BY 1 START WITH 1 CACHE 20 NOORDER  NOCYCLE  NOKEEP  NOSCALE  NOT NULL ENABLE, "STADIUM" VARCHAR2(255), "CITY" VARCHAR2(50), "STATE" VARCHAR2(50), "TEAM" VARCHAR2(255), "CONFERENCE" VARCHAR2(50), "BUILT" NUMBER, "DIV" VARCHAR2(50), "LATITUDE" NUMBER, "LONGITUDE" NUMBER, "CAPACITY" NUMBER, "EXPANDED" NUMBER, PRIMARY KEY ("ID")  USING INDEX  ENABLE   ) /
Then I created a new APEX app which automatically built the Faceted Search page.  For more info on how to build one from scratch, see this post by Carsten Czarski.  I tweaked the facets a bit to match the screen shots above.  At this point, I have everything but the Google Map, and it was very easy to get this far.
Here’s the outline for getting the filtered data into Google Maps:
Retrieve the filtered data set
Format it with JSON
Use an an Ajax Callback process to retrieve the JSON data
Use JavaScript to parse the JSON and call the Google APIs to render the map
Use a Dynamic Action to refresh the map when the facets change
Retrieve the Filtered Data Set:
Per Carsten’s blog post (the one I referenced first), we create a new type in the database for a single row of the result set:
CREATE OR REPLACE EDITIONABLE TYPE  "TYP_STADIUM_ROW" as object  (id                    NUMBER,   stadium               VARCHAR2(255),   city                  VARCHAR2(50),   state                 VARCHAR2(50),   team                  VARCHAR2(255),   conference            VARCHAR2(50),   built                 NUMBER,   div                   VARCHAR2(50),   latitude              NUMBER,   longitude             NUMBER,   capacity              NUMBER,   expanded              NUMBER  ) /
Then we create another new type as a table of that object:
CREATE OR REPLACE EDITIONABLE TYPE  "TYP_STADIUM_TABLE" as table of typ_stadium_row /
Then we create a pipelined function to get the filtered result set from our search results report region:
create or replace FUNCTION fn_get_stadium_search_data(p_page_id          IN NUMBER,                                                      p_region_static_id IN VARCHAR2) RETURN typ_stadium_table PIPELINED IS  l_region_id    NUMBER;  l_context      apex_exec.t_context;  TYPE t_col_index IS TABLE OF PLS_INTEGER INDEX BY VARCHAR2(255);  l_col_index    t_col_index;    ---------------------------------------------------------------------------    PROCEDURE get_column_indexes(p_columns wwv_flow_t_varchar2 ) IS    BEGIN      FOR i IN 1 .. p_columns.COUNT LOOP        l_col_index(p_columns(i)) := apex_exec.get_column_position(p_context     => l_context,                                                                   p_column_name => p_columns(i));      END LOOP;    END get_column_indexes;    --------------------------------------------------------------------------- BEGIN  -- 1. get the region ID of the Faceted Search region  SELECT  region_id  INTO    l_region_id  FROM    apex_application_page_regions  WHERE   application_id = v('APP_ID')  AND     page_id        = p_page_id  AND     static_id      = p_region_static_id;  -- 2. Get a cursor (apex_exec.t_context) for the current region data  l_context := apex_region.open_query_context(p_page_id      => p_page_id,                                              p_region_id    => l_region_id );  get_column_indexes(wwv_flow_t_varchar2('ID', 'STADIUM', 'CITY', 'STATE', 'TEAM', 'CONFERENCE', 'BUILT', 'DIV', 'LATITUDE', 'LONGITUDE', 'CAPACITY', 'EXPANDED'));  WHILE apex_exec.next_row(p_context => l_context) LOOP    PIPE ROW(typ_stadium_row(apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('ID'        )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('STADIUM'   )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('CITY'      )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('STATE'     )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('TEAM'      )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('CONFERENCE')),                             apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('BUILT'     )),                             apex_exec.get_varchar2(p_context => l_context, p_column_idx => l_col_index('DIV'       )),                             apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('LATITUDE'  )),                             apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('LONGITUDE' )),                             apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('CAPACITY'  )),                             apex_exec.get_number  (p_context => l_context, p_column_idx => l_col_index('EXPANDED'  ))                            )            );  END LOOP;  apex_exec.close(l_context);  RETURN; EXCEPTION WHEN NO_DATA_FOUND THEN   apex_exec.close(l_context);   RETURN; WHEN OTHERS THEN   apex_exec.close( l_context );   RAISE; END fn_get_stadium_search_data; /
The function has 2 parameters: 1 for the APEX page number and 1 for the region’s static ID.  My faceted search is page 3, and I set the static ID of my search results region to “searchResults”.   The function uses APEX_REGION and APEX_EXEC to get the individual values from the report region, then assembles them into a collection of the type we built above.
At this point, we could create a new chart region in our APEX page that queries this pipelined function instead of our BLOG_STADIUMS table.  A query would look something like this:
SELECT          id,                stadium,                city,                state,                team,                conference,                built,                div,                latitude,                longitude,                capacity,                expanded FROM            table(fn_get_stadium_search_data('3', 'searchResults'))
Format it with JSON:
In order to use the data with Google Maps, we need to format it with JSON.  Something like this will work:
{"map_boundaries": {"center_latitude": 30.61009758,        "center_longitude": -96.34072923,        "east_longitude": -96.34072923,        "north_latitude": 30.61009758,        "south_latitude": 30.61009758,        "west_longitude": -96.34072923     }, "stadium_data": [{"built": "1927",            "capacity": "102,733",            "city": "College Station",            "color": null,            "conference": "SEC",            "div": "fbs",            "expanded": "2015",            "id": "00004",            "latitude": 30.61009758,            "longitude": -96.34072923,            "stadium": "Kyle Field",            "state": "Texas",            "team": "Texas A&M"        },        {"built": "1924",            "capacity": "100,119",            "city": "Austin",             "color": null,            "conference": "Big 12",            "div": "fbs",            "expanded": "2009",            "id": "00008",            "latitude": 30.2836034,            "longitude": -97.73233652,            "stadium": "Darrell K Royal–Texas Memorial Stadium",            "state": "Texas",            "team": "Texas"         }     ] }
Notice that the JSON contains “map_boundaries” data.  We’ll use that data to (1) determine where to center the map, and (2) calculate a zoom level.
Here’s the code for a function that will build that:
create or replace FUNCTION fn_get_stadium_data_json RETURN CLOB IS  l_json_data                CLOB;  CURSOR c_stadiums IS    WITH stadiums_cte AS      (SELECT          id,                       stadium,                       city,                       state,                       team,                       conference,                       built,                       div,                       latitude,                       longitude,                       capacity,                       expanded       FROM            table(fn_get_stadium_search_data('3', 'searchResults'))      )    SELECT          JSON_OBJECT('map_boundaries' VALUE (SELECT          JSON_OBJECT('west_longitude'   VALUE min_lon,                                                                                    'east_longitude'   VALUE max_lon,                                                                                    'south_latitude'   VALUE min_lat,                                                                                    'north_latitude'   VALUE max_lat,                                                                                    'center_latitude'  VALUE NVL((min_lat + max_lat) / 2,  39.8333),                                                                                    'center_longitude' VALUE NVL((min_lon + max_lon) / 2, -98.5833)                                                                                    RETURNING CLOB)                                                        FROM            (SELECT          LEAST(   MIN(latitude),    90) AS min_lat,                                                                                         LEAST(   MIN(longitude),  180) AS min_lon,                                                                                         GREATEST(MAX(latitude),     0) AS max_lat,                                                                                         GREATEST(MAX(longitude), -180) AS max_lon                                                                         FROM            stadiums_cte                                                                        )                                                       ),                                'stadium_data'   VALUE (SELECT          JSON_ARRAYAGG(JSON_OBJECT('id'              VALUE TO_CHAR(id, 'FM00000'),                                                                                                  'stadium'         VALUE stadium,                                                                                                  'city'            VALUE city,                                                                                                  'state'           VALUE state,                                                                                                  'team'            VALUE team,                                                                                                  'conference'      VALUE conference,                                                                                                  'built'           VALUE TO_CHAR(built, 'FM0000'),                                                                                                  'div'             VALUE div,                                                                                                  'latitude'        VALUE latitude,                                                                                                  'longitude'       VALUE longitude,                                                                                                  'capacity'        VALUE TO_CHAR(capacity, 'FM999G990'),                                                                                                  'expanded'        VALUE TO_CHAR(expanded, 'FM9990'),                                                                                                  'color'           VALUE CAST(NULL AS VARCHAR2(12))                                                                                                  RETURNING CLOB)                                                                                      RETURNING CLOB)                                                        FROM            stadiums_cte                                                       )                                RETURNING CLOB) AS json_stadium_data    FROM            dual; BEGIN  --============================================================================================================================  -- Get all of the stadium data and put it in a PL/SQL collection.  --============================================================================================================================  OPEN  c_stadiums;  FETCH c_stadiums INTO l_json_data;  CLOSE c_stadiums;  RETURN(l_json_data); END fn_get_stadium_data_json; /
Notice that it calls the other function we built.  I’m sure the SQL could be simpler, but it works.
Ajax Callback Process to Retrieve JSON:
On page 3 of my APEX app, I created an Ajax Callback process with these properties:
Tumblr media
The name is “refreshMap” and it wraps our previous function with “htp.p”.
Use JS to Parse the JSON and Call the Google Map APIs:
Now that we have the data formatted with JSON, and we have a way to retrieve it via JS, we just need to do that and use the APIs to render the Google Map.  We’ll declare a JS function called “initMap” that will use the APEX JS API “apex.server.process” to execute the Ajax Callback Process we created above.  When the callback is successful, we’ll execute some JS to calculate an appropriate zoom level for the map, and then we’ll call google.maps.Map to render it.  Finally, we’ll loop through the JSON data and create a marker for each stadium.  I added some code to display a little info window when the mouse hovers over a marker.  [The info window works pretty well on Safari, but seems real twitchy in Chrome.  I’ll post any fixes I come up with.]
Here’s the code for the “Function and Global Variable Declaration” page property:
function initMap() { apex.server.process('refreshMap', // Process or AJAX Callback name                   {},           // No page items to pass/set                    {success: function (pData) {// Success Javascript                      var data = pData; //.substring(1, pData.length - 3);                      var json_data = JSON.parse(data);                      var GLOBE_WIDTH = 256; // a constant in Google's map projection                      // Calculate Zoom level based upon longitudes                      var pixelWidth = $("#map").width();                      var west   = json_data.map_boundaries.west_longitude;                      var east   = json_data.map_boundaries.east_longitude;                      var angle1 = east - west;                      if (angle1 < 0) {angle1 += 360;}                      var zoom1 = Math.floor(Math.log(pixelWidth * 360 / angle1 / GLOBE_WIDTH) / Math.LN2);                      // Calculate Zoom level based upon latitudes                      var pixelHeight = $("#map").height();                      var south  = json_data.map_boundaries.south_latitude;                      var north  = json_data.map_boundaries.north_latitude;                      var angle2 = north - south + 3;                      var zoom2  = Math.floor(Math.log(pixelHeight * 360 / angle2 / GLOBE_WIDTH) / Math.LN2);                      // Choose the lower of the 2 Zoom levels                      var zoom = Math.min(zoom1, zoom2);                      // If the zoom is calculated to infinity, then set it to 4.  This will occur when no stadiums are found.                      if (zoom === Infinity) {zoom = 4;}                      var map = new google.maps.Map(document.getElementById('map'), { zoom: zoom,                                      center: {lat: json_data.map_boundaries.center_latitude,                                       lng: json_data.map_boundaries.center_longitude                                              },                                      mapTypeId: 'terrain',                                      mapTypeControl: false,                                      streetViewControl: false                                    }                                                                             );                      var myLatLng;                    var marker;                    var infoWindow = new google.maps.InfoWindow();                      $.each(json_data.stadium_data, function(index, element) {                        myLatLng = {lat: element.latitude, lng: element.longitude};                        marker = new google.maps.Marker( {position: myLatLng,                                       map:      map,                                       title:    element.stadium                                       });                        // Open the InfoWindow on mouseover:                        google.maps.event.addListener(marker, 'mouseover', function(e) {                          infoWindow.setPosition(e.latLng);                          infoWindow.setContent("<table class=\"infoContent\"><tr>" +                                              "<td class=\"infoContentLabel\">Team:  </td>" +                                                "<td class=\"infoContentData\">" + element.team + "</td>" +                                                "</tr><tr>" +                                                "<td class=\"infoContentLabel\">Stadium:  </td>" +                                                "<td class=\"infoContentData\">" + element.stadium + "</td>"+                                                "</tr><tr>"+                                                "<td class=\"infoContentLabel\">Location: </td>"+                                                "<td class=\"infoContentData\">" + element.city + ", " + element.state + "</td>" +                                                "</tr><tr>"+                                                "<td class=\"infoContentLabel\">Capacity: </td>"+                                                "<td class=\"infoContentData\">" + element.capacity + "</td>" +                                                "</tr><tr>"+                                                "<td class=\"infoContentLabel\">Built: </td>"+                                                "<td class=\"infoContentData\">" + element.built + "</td>" +                                                "</tr></table>");                         infoWindow.open(map);                        });                        // Close the InfoWindow on mouseout:                        google.maps.event.addListener(marker, 'mouseout', function() {                          infoWindow.close();                        });                      });                                             },                     dataType: "text" // Response type (here: plain text)                    }                   ); }
Now we need to load the Google Maps API JS and provide them with an API key.  To do this, we add this JS code to the “Execute When Page Loads” page property:
(function(d, script) {    script = d.createElement('script');    script.type = 'text/javascript';    script.async = true;    script.onload = function(){        // remote script has loaded    };    script.src = 'https://maps.googleapis.com/maps/api/js?key=<yourKeyHere>&callback=initMap';    d.getElementsByTagName('body')[0].appendChild(script); }(document));
And finally, I added this to the “Inline” CSS page property:
/* Always set the map height explicitly to define the size of the div * element that contains the map. */ #map {  height: 600px; } .infoContentLabel {  font-weight: bold; }
Dynamic Action to Refresh the Map
The last step is to get the map to refresh dynamically when the facets change.  Create a new Dynamic Action named “When Search Changes” that fires on the “After Refresh” event for the search results region (the name is “Stadiums”, the static ID is “searchResults”).  It has one TRUE action - Execute JavaScript Code with the following code:
initMap(); //setTimeout(function(){ initMap(); }, 750);
Note: The “Facets Change” event seemed to cause a race condition between the map region and the search results region.  Sometimes it would show the latest info, sometimes it wouldn’t.  I was able to use a JS timeout of about .75 seconds to work-around that, but I found that just refreshing the map after the refresh of the search results worked better.
0 notes