au.org.ala.biocache.web.WMSController.java Source code

Java tutorial

Introduction

Here is the source code for au.org.ala.biocache.web.WMSController.java

Source

/**************************************************************************
 *  Copyright (C) 2013 Atlas of Living Australia
 *  All Rights Reserved.
 * 
 *  The contents of this file are subject to the Mozilla Public
 *  License Version 1.1 (the "License"); you may not use this file
 *  except in compliance with the License. You may obtain a copy of
 *  the License at http://www.mozilla.org/MPL/
 * 
 *  Software distributed under the License is distributed on an "AS
 *  IS" basis, WITHOUT WARRANTY OF ANY KIND, either express or
 *  implied. See the License for the specific language governing
 *  rights and limitations under the License.
 ***************************************************************************/
package au.org.ala.biocache.web;

import au.org.ala.biocache.dao.SearchDAO;
import au.org.ala.biocache.dao.TaxonDAO;
import au.org.ala.biocache.dto.*;
import au.org.ala.biocache.util.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.commons.lang.ArrayUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.log4j.Logger;
import org.apache.solr.common.SolrDocument;
import org.apache.solr.common.SolrDocumentList;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;

import javax.imageio.ImageIO;
import javax.inject.Inject;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.*;
import java.net.URL;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.util.*;
import java.util.List;

/**
 * This controller provides mapping services which include WMS services. Includes support for:
 *
 * <ul>
 *    <li>GetCapabilities</li>
 *    <li>GetMap</li>
 *    <li>GetFeatureInfo</li>
 *    <li>GetMetadata</li>
 * </ul>
 */
@Controller
public class WMSController {

    /** webportal results limit */
    private final int DEFAULT_PAGE_SIZE = 1000000;
    /** categorical colours */
    private final int[] colourList = { 0x003366CC, 0x00DC3912, 0x00FF9900, 0x00109618, 0x00990099, 0x000099C6,
            0x00DD4477, 0x0066AA00, 0x00B82E2E, 0x00316395, 0x00994499, 0x0022AA99, 0x00AAAA11, 0x006633CC,
            0x00E67300, 0x008B0707, 0x00651067, 0x00329262, 0x005574A6, 0x003B3EAC, 0x00B77322, 0x0016D620,
            0x00B91383, 0x00F4359E, 0x009C5935, 0x00A9C413, 0x002A778D, 0x00668D1C, 0x00BEA413, 0x000C5922,
            0x00743411 };
    //For WMS services
    final String[] colorsNames = new String[] { "DarkRed", "IndianRed", "DarkSalmon", "SaddleBrown", "Chocolate",
            "SandyBrown", "Orange", "DarkGreen", "Green", "Lime", "LightGreen", "MidnightBlue", "Blue", "SteelBlue",
            "CadetBlue", "Aqua", "PowderBlue", "DarkOliveGreen", "DarkKhaki", "Yellow", "Moccasin", "Indigo",
            "Purple", "Fuchsia", "Plum", "Black", "White" };
    final String[] colorsCodes = new String[] { "8b0000", "FF0000", "CD5C5C", "E9967A", "8B4513", "D2691E",
            "F4A460", "FFA500", "006400", "008000", "00FF00", "90EE90", "191970", "0000FF", "4682B4", "5F9EA0",
            "00FFFF", "B0E0E6", "556B2F", "BDB76B", "FFFF00", "FFE4B5", "4B0082", "800080", "FF00FF", "DDA0DD",
            "000000", "FFFFFF" };

    private final int DEFAULT_COLOUR = 0x00000000;
    /** webportal image max pixel count */
    private final int MAX_IMAGE_PIXEL_COUNT = 36000000; //this is slightly larger than 600dpi A4
    /** legend limits */
    private final String NULL_NAME = "Unknown";
    /** max uncertainty mappable in m */
    private final double MAX_UNCERTAINTY = 30000;
    /** add pixel radius for wms highlight circles */
    private final static int HIGHLIGHT_RADIUS = 3;
    /** Logger initialisation */
    private final static Logger logger = Logger.getLogger(WMSController.class);
    /** Fulltext search DAO */
    @Inject
    protected SearchDAO searchDAO;
    @Inject
    protected TaxonDAO taxonDAO;
    @Inject
    protected SearchUtils searchUtils;
    /** Load a smaller 256x256 png than java.image produces */
    final static byte[] blankImageBytes;

    @Value("${webservices.root:http://biocache.ala.org.au/ws}")
    protected String baseWsUrl;
    @Value("${geoserver.url:http://spatial.ala.org.au/geoserver}")
    protected String geoserverUrl;

    @Value("${organizationName:Atlas of Living Australia}")
    protected String organizationName;
    @Value("${orgCity:Canberra}")
    protected String orgCity;
    @Value("${orgStateProvince:ACT}")
    protected String orgStateProvince;
    @Value("${orgPostcode:2601}")
    protected String orgPostcode;
    @Value("${orgCountry:Australia}")
    protected String orgCountry;
    @Value("${orgPhone:+61 (0) 2 6246 4400}")
    protected String orgPhone;
    @Value("${orgFax:+61 (0) 2 6246 4400}")
    protected String orgFax;
    @Value("${orgEmail:support@ala.org.au}")
    protected String orgEmail;

    static {
        byte[] b = null;
        try {
            RandomAccessFile raf = new RandomAccessFile(WMSController.class.getResource("/blank.png").getFile(),
                    "r");
            b = new byte[(int) raf.length()];
            raf.read(b);
            raf.close();
        } catch (Exception e) {
            logger.error("Unable to open blank image file");
        }
        blankImageBytes = b;
    }

    /**
     * Store query params list
     */
    @RequestMapping(value = { "/webportal/params", "/mapping/params" }, method = RequestMethod.POST)
    public void storeParams(SpatialSearchRequestParams requestParams,
            @RequestParam(value = "bbox", required = false, defaultValue = "false") String bbox,
            @RequestParam(value = "title", required = false) String title, HttpServletResponse response)
            throws Exception {

        //get bbox (also cleans up Q)
        double[] bb = null;
        if (bbox != null && bbox.equals("true")) {
            bb = getBBox(requestParams);
        } else {
            //get a formatted Q by running a query
            requestParams.setPageSize(0);
            searchDAO.findByFulltext(requestParams);
        }

        //store the title if necessary
        if (title == null)
            title = requestParams.getDisplayString();
        String[] fqs = getFq(requestParams);
        if (fqs != null && fqs.length == 1 && fqs[0].length() == 0) {
            fqs = null;
        }
        Long qid = ParamsCache.put(requestParams.getFormattedQuery(), title, requestParams.getWkt(), bb, fqs);
        response.setContentType("text/plain");
        writeBytes(response, qid.toString().getBytes());
    }

    /**
     * Test presence of query params {id} in params store.
     */
    @RequestMapping(value = { "/webportal/params/{id}", "/mapping/params/{id}" }, method = RequestMethod.GET)
    public @ResponseBody Boolean storeParams(@PathVariable("id") Long id) throws Exception {
        return ParamsCache.get(id) != null;
    }

    /**
     * Allows the details of a cached query to be viewed.
     * @param id
     * @return
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/params/details/{id}",
            "/mapping/params/details/{id}" }, method = RequestMethod.GET)
    public @ResponseBody ParamsCacheObject getParamCacheObject(@PathVariable("id") Long id) throws Exception {
        return ParamsCache.get(id);
    }

    /**
     *
     * JSON web service that returns a list of species and record counts for a given location search
     *
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/species", "/mapping/species" }, method = RequestMethod.GET)
    public @ResponseBody List<TaxaCountDTO> listSpecies(SpatialSearchRequestParams requestParams) throws Exception {
        return searchDAO.findAllSpecies(requestParams);
    }

    /**
     *
     * List of species for webportal as csv.
     *
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/species.csv", "/mapping/species.csv" }, method = RequestMethod.GET)
    public void listSpeciesCsv(SpatialSearchRequestParams requestParams, HttpServletResponse response)
            throws Exception {

        List<TaxaCountDTO> list = searchDAO.findAllSpecies(requestParams);

        //format as csv
        StringBuilder sb = new StringBuilder();
        sb.append("Family,Scientific name,Common name,Taxon rank,LSID,# Occurrences");
        for (TaxaCountDTO d : list) {
            String family = d.getFamily();
            String name = d.getName();
            String commonName = d.getCommonName();
            String guid = d.getGuid();
            String rank = d.getRank();

            if (family == null) {
                family = "";
            }
            if (name == null) {
                name = "";
            }
            if (commonName == null) {
                commonName = "";
            }

            if (d.getGuid() == null) {
                //when guid is empty name contains name_lsid value.
                if (d.getName() != null) {
                    //parse name
                    String[] nameLsid = d.getName().split("\\|");
                    if (nameLsid.length >= 2) {
                        name = nameLsid[0];
                        guid = nameLsid[1];
                        rank = "scientific name";

                        if (nameLsid.length >= 3) {
                            commonName = nameLsid[2];
                        }
                        //                        if(nameLsid.length >= 4) {
                        //                            kingdom = nameLsid[3];
                        //                        }
                    } else {
                        name = NULL_NAME;
                    }
                }
            }
            if (d.getCount() != null && guid != null) {
                sb.append("\n\"").append(family.replace("\"", "\"\"").trim()).append("\",\"")
                        .append(name.replace("\"", "\"\"").trim()).append("\",\"")
                        .append(commonName.replace("\"", "\"\"").trim()).append("\",").append(rank).append(",")
                        .append(guid).append(",").append(d.getCount());
            }
        }

        writeBytes(response, sb.toString().getBytes("UTF-8"));
    }

    /**
     * Get legend for a query and facet field (colourMode).
     *
     *if "Accept" header is application/json return json otherwise
     *
     * @param requestParams
     * @param colourMode
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/legend", "/mapping/legend" }, method = RequestMethod.GET)
    @ResponseBody
    public List<LegendItem> legend(SpatialSearchRequestParams requestParams,
            @RequestParam(value = "cm", required = false, defaultValue = "") String colourMode,
            @RequestParam(value = "type", required = false, defaultValue = "application/csv") String returnType,
            HttpServletRequest request, HttpServletResponse response) throws Exception {

        String[] acceptableTypes = new String[] { "application/json", "application/csv" };

        String accepts = request.getHeader("Accept");
        //only allow a single format to be supplied in the header otherwise use the default returnType
        returnType = StringUtils.isNotEmpty(accepts) && !accepts.contains(",") ? accepts : returnType;
        if (!Arrays.asList(acceptableTypes).contains(returnType)) {
            response.sendError(response.SC_NOT_ACCEPTABLE,
                    "Unable to produce a legend in the supplied \"Accept\" format: " + returnType);
            return null;
        }
        boolean isCsv = returnType.equals("application/csv");
        //test for cutpoints on the back of colourMode
        String[] s = colourMode.split(",");
        String[] cutpoints = null;
        if (s.length > 1) {
            cutpoints = new String[s.length - 1];
            System.arraycopy(s, 1, cutpoints, 0, cutpoints.length);
        }
        List<LegendItem> legend = searchDAO.getLegend(requestParams, s[0], cutpoints);
        if (cutpoints == null) {
            java.util.Collections.sort(legend);
        }
        StringBuilder sb = new StringBuilder();
        if (isCsv) {
            sb.append("name,red,green,blue,count");
        }
        int i = 0;
        //add legend entries.
        int offset = 0;
        for (i = 0; i < legend.size(); i++) {
            LegendItem li = legend.get(i);
            String name = li.getName();
            if (name == null) {
                name = NULL_NAME;
            }
            int colour = DEFAULT_COLOUR;
            if (cutpoints == null) {
                colour = colourList[Math.min(i, colourList.length - 1)];
            } else if (cutpoints != null && i - offset < cutpoints.length) {
                if (name.equals(NULL_NAME) || name.startsWith("-")) {
                    offset++;
                    colour = DEFAULT_COLOUR;
                } else {
                    colour = getRangedColour(i - offset, cutpoints.length / 2);
                }
            }
            li.setRGB(colour);
            if (isCsv) {
                sb.append("\n\"").append(name.replace("\"", "\"\"")).append("\",").append(getRGB(colour)) //repeat last colour if required
                        .append(",").append(legend.get(i).getCount());
            }
        }

        //now generate the JSON if necessary
        if (returnType.equals("application/json")) {
            return legend;
        } else {
            writeBytes(response, sb.toString().getBytes("UTF-8"));
            return null;
        }
    }

    /**
     * List data providers for a query.
     *
     * @param requestParams
     * @return
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/dataProviders", "/mapping/dataProviders" }, method = RequestMethod.GET)
    @ResponseBody
    public List<DataProviderCountDTO> queryInfo(SpatialSearchRequestParams requestParams) throws Exception {
        return searchDAO.getDataProviderList(requestParams);
    }

    /**
     * Get query bounding box as csv containing:
     *  min longitude, min latitude, max longitude, max latitude
     *
     * @param requestParams
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/bbox", "/mapping/bbox" }, method = RequestMethod.GET)
    public void boundingBox(SpatialSearchRequestParams requestParams, HttpServletResponse response)
            throws Exception {

        double[] bbox = null;

        String q = requestParams.getQ();
        if (q.startsWith("qid:")) {
            try {
                bbox = ParamsCache.get(Long.parseLong(q.substring(4))).getBbox();
            } catch (Exception e) {
            }
        }

        if (bbox == null) {
            bbox = getBBox(requestParams);
        }

        writeBytes(response, (bbox[0] + "," + bbox[1] + "," + bbox[2] + "," + bbox[3]).getBytes("UTF-8"));
    }

    /**
     * Get query bounding box as JSON array containing:
     *  min longitude, min latitude, max longitude, max latitude
     * 
     * @param requestParams
     * @param response
     * @return
     * @throws Exception 
     */
    @RequestMapping(value = { "/webportal/bounds", "/mapping/bounds" }, method = RequestMethod.GET)
    public @ResponseBody double[] jsonBoundingBox(SpatialSearchRequestParams requestParams,
            HttpServletResponse response) throws Exception {

        double[] bbox = null;

        String q = requestParams.getQ();
        if (q.startsWith("qid:")) {
            try {
                bbox = ParamsCache.get(Long.parseLong(q.substring(4))).getBbox();
            } catch (Exception e) {
            }
        }

        if (bbox == null) {
            bbox = getBBox(requestParams);
        }

        return bbox;
    }

    /**
     * Get occurrences by query as JSON.
     *
     * @param requestParams
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/occurrences*", "/mapping/occurrences*" }, method = RequestMethod.GET)
    @ResponseBody
    public SearchResultDTO occurrences(SpatialSearchRequestParams requestParams, Model model) throws Exception {

        SearchResultDTO searchResult = new SearchResultDTO();

        if (StringUtils.isEmpty(requestParams.getQ())) {
            return searchResult;
        }

        //searchUtils.updateSpatial(requestParams);
        searchResult = searchDAO.findByFulltextSpatialQuery(requestParams, null);
        model.addAttribute("searchResult", searchResult);

        if (logger.isDebugEnabled()) {
            logger.debug("Returning results set with: " + searchResult.getTotalRecords());
        }

        return searchResult;
    }

    /**
     * Get occurrences by query as gzipped csv.
     *
     * @param requestParams
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/occurrences.gz", "/mapping/occurrences.gz" }, method = RequestMethod.GET)
    public void occurrenceGz(SpatialSearchRequestParams requestParams, HttpServletResponse response)
            throws Exception {

        response.setContentType("text/plain");
        response.setCharacterEncoding("gzip");

        ServletOutputStream outStream = response.getOutputStream();
        java.util.zip.GZIPOutputStream gzip = new java.util.zip.GZIPOutputStream(outStream);

        writeOccurrencesCsvToStream(requestParams, gzip);

        gzip.flush();
        gzip.close();
    }

    private void writeOccurrencesCsvToStream(SpatialSearchRequestParams requestParams, OutputStream stream)
            throws Exception {
        SolrDocumentList sdl = searchDAO.findByFulltext(requestParams);

        byte[] bComma = ",".getBytes("UTF-8");
        byte[] bNewLine = "\n".getBytes("UTF-8");
        byte[] bDblQuote = "\"".getBytes("UTF-8");

        if (sdl != null && sdl.size() > 0) {
            //header field identification
            ArrayList<String> header = new ArrayList<String>();
            if (requestParams.getFl() == null || requestParams.getFl().isEmpty()) {
                TreeSet<String> unique = new TreeSet<String>();
                for (int i = 0; i < sdl.size(); i++) {
                    unique.addAll(sdl.get(i).getFieldNames());
                }
                header = new ArrayList<String>(unique);
            } else {
                String[] fields = requestParams.getFl().split(",");
                for (int i = 0; i < fields.length; i++) {
                    if (fields[i].length() > 0) {
                        header.add(fields[i]);
                    }
                }
            }

            //write header
            for (int i = 0; i < header.size(); i++) {
                if (i > 0) {
                    stream.write(bComma);
                }
                stream.write(header.get(i).getBytes("UTF-8"));
            }

            //write records
            for (int i = 0; i < sdl.size(); i++) {
                stream.write(bNewLine);
                for (int j = 0; j < header.size(); j++) {
                    if (j > 0) {
                        stream.write(bComma);
                    }
                    if (sdl.get(i).containsKey(header.get(j))) {
                        stream.write(bDblQuote);
                        stream.write(String.valueOf(sdl.get(i).getFieldValue(header.get(j))).replace("\"", "\"\"")
                                .getBytes("UTF-8"));
                        stream.write(bDblQuote);
                    }
                }
            }
        }
    }

    private void writeBytes(HttpServletResponse response, byte[] bytes) throws IOException {
        response.setContentType("text/plain");
        response.setCharacterEncoding("UTF-8");
        ServletOutputStream outStream = response.getOutputStream();
        outStream.write(bytes);
        outStream.flush();
        outStream.close();
    }

    /** 4326 to 900913 pixel and m conversion */
    private int map_offset = 268435456; // half the Earth's circumference at zoom level 21
    private double map_radius = map_offset / Math.PI;

    int convertLatToPixel(double lat) {
        return (int) Math.round(map_offset - map_radius
                * Math.log((1 + Math.sin(lat * Math.PI / 180)) / (1 - Math.sin(lat * Math.PI / 180))) / 2);
    }

    int convertLatToPixel4326(double lat, double top, double bottom, int pixelHeight) {
        return (int) (((lat - top) / (bottom - top)) * pixelHeight);
    }

    int convertLngToPixel4326(double lng, double left, double right, int pixelWidth) {
        return (int) (((lng - left) / (right - left)) * pixelWidth);
    }

    int convertLngToPixel(double lng) {
        return (int) Math.round(map_offset + map_radius * lng * Math.PI / 180);
    }

    double convertMetersToLng(double meters) {
        return meters / 20037508.342789244 * 180;
    }

    //    //http://mapsforge.googlecode.com/svn-history/r1841/trunk/mapsforge-map-writer/src/main/java/org/mapsforge/map/writer/model/MercatorProjection.java
    double convertLngToMeters(double lng) {
        return 6378137.0 * Math.PI / 180 * lng;
    }

    //
    //    public static final double WGS_84_EQUATORIALRADIUS = 6378137.0;
    double convertLatToMeters(double lat) {
        return 6378137.0 * Math.log(Math.tan(Math.PI / 180 * (45 + lat / 2.0)));
    }

    double convertMetersToLat(double meters) {
        return 180.0 / Math.PI * (2 * Math.atan(Math.exp(meters / 20037508.342789244 * Math.PI)) - Math.PI / 2.0);
    }

    /**
     * Map a zoom level to a coordinate accuracy level
     *
     * @return
     */
    protected PointType getPointTypeForDegreesPerPixel(double resolution) {
        PointType pointType = null;
        // Map zoom levels to lat/long accuracy levels
        if (resolution >= 1) {
            pointType = PointType.POINT_1;
        } else if (resolution >= 0.1) {
            pointType = PointType.POINT_01;
        } else if (resolution >= 0.01) {
            pointType = PointType.POINT_001;
        } else if (resolution >= 0.001) {
            pointType = PointType.POINT_0001;
        } else if (resolution >= 0.0001) {
            pointType = PointType.POINT_00001;
        } else {
            pointType = PointType.POINT_RAW;
        }
        return pointType;
    }

    void displayBlankImage(HttpServletResponse response) {
        try {
            ServletOutputStream outStream = response.getOutputStream();
            outStream.write(blankImageBytes);
            outStream.flush();
            outStream.close();
        } catch (Exception e) {
            logger.error("Unable to write image", e);
        }
    }

    /**
     *
     * @param bboxString
     * @param width
     * @param height
     * @param size
     * @param uncertainty
     * @param mbbox  the mbbox to initialise
     * @param bbox  the bbox to initialise
     * @param pbbox  the pbbox to initialise
     * @return
     */
    private double getBBoxes(String bboxString, int width, int height, int size, boolean uncertainty,
            double[] mbbox, double[] bbox, double[] pbbox, double[] tilebbox) {
        int i = 0;
        for (String s : bboxString.split(",")) {
            try {
                tilebbox[i] = Double.parseDouble(s);
                mbbox[i] = tilebbox[i];
                i++;
            } catch (Exception e) {
                logger.error("Problem parsing BBOX: '" + bboxString + "'", e);
            }
        }

        //adjust bbox extents with half pixel width/height
        double pixelWidth = (mbbox[2] - mbbox[0]) / width;
        double pixelHeight = (mbbox[3] - mbbox[1]) / height;
        mbbox[0] += pixelWidth / 2;
        mbbox[2] -= pixelWidth / 2;
        mbbox[1] += pixelHeight / 2;
        mbbox[3] -= pixelHeight / 2;

        //offset for points bounding box by dot size
        double xoffset = (mbbox[2] - mbbox[0]) / (double) width * size;
        double yoffset = (mbbox[3] - mbbox[1]) / (double) height * size;

        //check offset for points bb by maximum uncertainty
        if (uncertainty) {
            if (xoffset < MAX_UNCERTAINTY) {
                xoffset = MAX_UNCERTAINTY;
            }
            if (yoffset < MAX_UNCERTAINTY) {
                yoffset = MAX_UNCERTAINTY;
            }
        }

        //adjust offset for pixel height/width
        xoffset += pixelWidth;
        yoffset += pixelHeight;

        pbbox[0] = convertLngToPixel(convertMetersToLng(mbbox[0]));
        pbbox[1] = convertLatToPixel(convertMetersToLat(mbbox[1]));
        pbbox[2] = convertLngToPixel(convertMetersToLng(mbbox[2]));
        pbbox[3] = convertLatToPixel(convertMetersToLat(mbbox[3]));

        bbox[0] = convertMetersToLng(mbbox[0] - xoffset);
        bbox[1] = convertMetersToLat(mbbox[1] - yoffset);
        bbox[2] = convertMetersToLng(mbbox[2] + xoffset);
        bbox[3] = convertMetersToLat(mbbox[3] + yoffset);

        double degreesPerPixel = Math.min(
                (convertMetersToLng(mbbox[2]) - convertMetersToLng(mbbox[0])) / (double) width,
                (convertMetersToLng(mbbox[3]) - convertMetersToLng(mbbox[1])) / (double) height);
        return degreesPerPixel;
    }

    /**
     *
     * @param bboxString
     * @param width
     * @param height
     * @param size
     * @param uncertainty
     * @param mbbox  the mbbox to initialise
     * @param bbox  the bbox to initialise
     * @param pbbox  the pbbox to initialise
     * @return
     */
    private double getBBoxes4326(String bboxString, int width, int height, int size, boolean uncertainty,
            double[] mbbox, double[] bbox, double[] pbbox, double[] tilebbox) {
        int i = 0;
        for (String s : bboxString.split(",")) {
            try {
                tilebbox[i] = Double.parseDouble(s);
                mbbox[i] = tilebbox[i];
                i++;
            } catch (Exception e) {
                logger.error("Problem parsing BBOX: '" + bboxString + "'", e);
            }
        }

        //adjust bbox extents with half pixel width/height
        double pixelWidth = (mbbox[2] - mbbox[0]) / width;
        double pixelHeight = (mbbox[3] - mbbox[1]) / height;
        mbbox[0] += pixelWidth / 2;
        mbbox[2] -= pixelWidth / 2;
        mbbox[1] += pixelHeight / 2;
        mbbox[3] -= pixelHeight / 2;

        //offset for points bounding box by dot size
        double xoffset = (mbbox[2] - mbbox[0]) / (double) width * size;
        double yoffset = (mbbox[3] - mbbox[1]) / (double) height * size;

        //check offset for points bb by maximum uncertainty
        if (uncertainty) {
            //estimate 0.01 degrees is 1000m
            double scale = 0.01 / 1000;
            if (xoffset < MAX_UNCERTAINTY * scale) {
                xoffset = MAX_UNCERTAINTY * scale;
            }
            if (yoffset < MAX_UNCERTAINTY * scale) {
                yoffset = MAX_UNCERTAINTY * scale;
            }
        }

        //adjust offset for pixel height/width
        xoffset += pixelWidth;
        yoffset += pixelHeight;

        /* not required for 4326
        pbbox[0] = convertLngToPixel(convertMetersToLng(mbbox[0]));
        pbbox[1] = convertLatToPixel(convertMetersToLat(mbbox[1]));
        pbbox[2] = convertLngToPixel(convertMetersToLng(mbbox[2]));
        pbbox[3] = convertLatToPixel(convertMetersToLat(mbbox[3]));
        */

        //actual bounding box
        bbox[0] = mbbox[0] - xoffset;
        bbox[1] = mbbox[1] - yoffset;
        bbox[2] = mbbox[2] + xoffset;
        bbox[3] = mbbox[3] + yoffset;

        double degreesPerPixel = Math.min(pixelWidth, pixelHeight);
        return degreesPerPixel;
    }

    private String getQ(String cql_filter) {
        String q = cql_filter;
        int p1 = cql_filter.indexOf("qid:");
        if (p1 >= 0) {
            int p2 = cql_filter.indexOf('&', p1 + 1);
            if (p2 < 0) {
                p2 = cql_filter.indexOf(';', p1 + 1);
            }
            if (p2 < 0) {
                p2 = cql_filter.length();
            }
            q = cql_filter.substring(p1, p2);
        }
        return q;
    }

    /**
     * Get legend items for the first colourList.length-1 items only.
     *
     * @param colourMode
     * @throws Exception
     */
    private List<LegendItem> getColours(SpatialSearchRequestParams request, String colourMode) throws Exception {
        List<LegendItem> colours = new ArrayList<LegendItem>();
        if (colourMode.equals("grid")) {
            for (int i = 0; i <= 500; i += 100) {
                LegendItem li;
                if (i == 0) {
                    li = new LegendItem(">0", 0, null);
                } else {
                    li = new LegendItem(String.valueOf(i), 0, null);
                }
                li.setColour((((500 - i) / 2) << 8) | 0x00FF0000);
                colours.add(li);
            }
        } else {
            SpatialSearchRequestParams requestParams = new SpatialSearchRequestParams();
            requestParams.setQ(request.getQ());
            requestParams.setQc(request.getQc());
            requestParams.setFq(getFq(request));

            //test for cutpoints on the back of colourMode
            String[] s = colourMode.split(",");
            String[] cutpoints = null;
            if (s.length > 1) {
                cutpoints = new String[s.length - 1];
                System.arraycopy(s, 1, cutpoints, 0, cutpoints.length);
            }
            if (s[0].equals("-1") || s[0].equals("grid")) {
                return null;
            } else {
                List<LegendItem> legend = searchDAO.getLegend(requestParams, s[0], cutpoints);

                if (cutpoints == null) { //do not sort if cutpoints are provided
                    java.util.Collections.sort(legend);
                }
                int i = 0;
                int offset = 0;
                for (i = 0; i < legend.size() && i < colourList.length - 1; i++) {
                    colours.add(new LegendItem(legend.get(i).getName(), legend.get(i).getCount(),
                            legend.get(i).getFq()));
                    int colour = DEFAULT_COLOUR;
                    if (cutpoints == null) {
                        colour = colourList[i];
                    } else if (cutpoints != null && i - offset < cutpoints.length) {
                        if (legend.get(i).getName() == null || legend.get(i).getName().equals(NULL_NAME)
                                || legend.get(i).getName().startsWith("-")) {
                            offset++;
                        } else {
                            colour = getRangedColour(i - offset, cutpoints.length / 2);
                        }
                    }
                    colours.get(colours.size() - 1).setColour(colour);
                }
            }
        }

        return colours;
    }

    int getRangedColour(int pos, int length) {
        int[] colourRange = { 0x00002DD0, 0x00005BA2, 0x00008C73, 0x0000B944, 0x0000E716, 0x00A0FF00, 0x00FFFF00,
                0x00FFC814, 0x00FFA000, 0x00FF5B00, 0x00FF0000 };

        double step = 1 / (double) colourRange.length;
        double p = pos / (double) (length);
        double dist = p / step;

        int minI = (int) Math.floor(dist);
        int maxI = (int) Math.ceil(dist);
        if (maxI >= colourRange.length) {
            maxI = colourRange.length - 1;
        }

        double minorP = p - (minI * step);
        double minorDist = minorP / step;

        //scale RGB individually
        int colour = 0x00000000;
        for (int i = 0; i < 3; i++) {
            int minC = (colourRange[minI] >> (i * 8)) & 0x000000ff;
            int maxC = (colourRange[maxI] >> (i * 8)) & 0x000000ff;
            int c = Math.min((int) ((maxC - minC) * minorDist + minC), 255);

            colour = colour | ((c & 0x000000ff) << (i * 8));
        }

        return colour;
    }

    String getRGB(int colour) {
        return ((colour >> 16) & 0x000000ff) + "," + ((colour >> 8) & 0x000000ff) + "," + (colour & 0x000000ff);
    }

    /**
     * Get bounding box for a query.
     *
     * @param requestParams
     * @return
     * @throws Exception
     */
    double[] getBBox(SpatialSearchRequestParams requestParams) throws Exception {
        double[] bbox = new double[4];
        String[] sort = { "longitude", "latitude", "longitude", "latitude" };
        String[] dir = { "asc", "asc", "desc", "desc" };

        //remove instances of null longitude or latitude
        String[] fq = (String[]) ArrayUtils.addAll(getFq(requestParams),
                new String[] { "longitude:[* TO *]", "latitude:[* TO *]" });
        requestParams.setFq(fq);
        requestParams.setPageSize(10);

        for (int i = 0; i < sort.length; i++) {
            requestParams.setSort(sort[i]);
            requestParams.setDir(dir[i]);
            requestParams.setFl(sort[i]);

            SolrDocumentList sdl = searchDAO.findByFulltext(requestParams);
            if (sdl != null && sdl.size() > 0) {
                if (sdl.get(0) != null) {
                    bbox[i] = (Double) sdl.get(0).getFieldValue(sort[i]);
                } else {
                    logger.error("searchDAO.findByFulltext returning SolrDocumentList with null records");
                }
            }
        }
        return bbox;
    }

    private String convertBBox4326To900913(String bbox) {
        int i = 0;
        Double[] mbbox = new Double[4];
        for (String s : bbox.split(",")) {
            if (i % 2 == 0)
                mbbox[i] = convertLngToMeters(Double.parseDouble(s));
            else
                mbbox[i] = convertLatToMeters(Double.parseDouble(s));
            i++;
        }
        return StringUtils.join(mbbox, ",");
    }

    // add this to the GetCapabilities...
    @RequestMapping(value = { "/ogc/getMetadata" }, method = RequestMethod.GET)
    public String getMetadata(@RequestParam(value = "LAYER", required = false, defaultValue = "") String layer,
            @RequestParam(value = "q", required = false, defaultValue = "") String query,
            HttpServletRequest request, HttpServletResponse response, Model model) throws Exception {

        //System.out.println("GETMETADATA: " + request.getQueryString());

        String taxonName = "";
        String rank = "";
        String q = "";
        if (StringUtils.trimToNull(layer) != null) {
            String[] parts = layer.split(":");
            taxonName = parts[parts.length - 1];
            if (parts.length > 1) {
                rank = parts[0];
            }
            q = layer;
        } else if (StringUtils.trimToNull(query) != null) {
            String[] parts = query.split(":");
            taxonName = parts[parts.length - 1];
            if (parts.length > 1) {
                rank = parts[0];
            }
            q = query;
        } else {
            response.sendError(400);
        }

        //http://bie.ala.org.au/ws/guid/Carcharodon%20carcharias  - get the guid
        ObjectMapper om = new ObjectMapper();
        String guid = null;
        JsonNode guidLookupNode = om
                .readTree(new URL("http://bie.ala.org.au/ws/guid/" + URLEncoder.encode(taxonName, "UTF-8")));
        //NC: Fixed the ArraryOutOfBoundsException when the lookup fails to yield a result
        if (guidLookupNode.isArray() && guidLookupNode.size() > 0) {
            JsonNode idNode = guidLookupNode.get(0).get("acceptedIdentifier");//NC: changed to used the acceptedIdentifier because this will always hold the guid for the accepted taxon concept whether or not a synonym name is provided
            guid = idNode != null ? idNode.asText() : null;
        }
        String newQuery = "raw_name:" + taxonName;
        if (guid != null) {

            model.addAttribute("guid", guid);
            model.addAttribute("speciesPageUrl", "http://bie.ala.org.au/species/" + guid);
            JsonNode node = om.readTree(new URL("http://bie.ala.org.au/ws/species/info/" + guid + ".json"));
            JsonNode tc = node.get("taxonConcept");
            JsonNode imageNode = tc.get("smallImageUrl");
            String imageUrl = imageNode != null ? imageNode.asText() : null;
            if (imageUrl != null) {
                model.addAttribute("imageUrl", imageUrl);
                JsonNode imageMetadataNode = node.get("taxonConcept").get("imageMetadataUrl");
                String imageMetadataUrl = imageMetadataNode != null ? imageMetadataNode.asText() : null;

                //image metadata
                JsonNode imageMetadata = om.readTree(new URL(imageMetadataUrl));
                if (imageMetadata != null) {
                    if (imageMetadata.get("http://purl.org/dc/elements/1.1/creator") != null)
                        model.addAttribute("imageCreator",
                                imageMetadata.get("http://purl.org/dc/elements/1.1/creator").asText());
                    if (imageMetadata.get("http://purl.org/dc/elements/1.1/license") != null)
                        model.addAttribute("imageLicence",
                                imageMetadata.get("http://purl.org/dc/elements/1.1/license").asText());
                    if (imageMetadata.get("http://purl.org/dc/elements/1.1/source") != null)
                        model.addAttribute("imageSource",
                                imageMetadata.get("http://purl.org/dc/elements/1.1/source").asText());
                }
            }

            JsonNode leftNode = tc.get("left");
            JsonNode rightNode = tc.get("right");
            newQuery = leftNode != null && rightNode != null
                    ? "lft:[" + leftNode.asText() + " TO " + rightNode.asText() + "]"
                    : "taxon_concept_lsid:" + guid;
            logger.debug("The new query : " + newQuery);

            //common name
            JsonNode commonNameNode = tc.get("commonNameSingle");
            if (commonNameNode != null) {
                model.addAttribute("commonName", commonNameNode.asText());
                logger.debug("retrieved name: " + commonNameNode.asText());
            }

            //name
            JsonNode nameNode = tc.get("nameComplete");
            if (nameNode != null) {
                model.addAttribute("name", nameNode.asText());
                logger.debug("retrieved name: " + nameNode.asText());
            }

            //authorship
            JsonNode authorshipNode = node.get("taxonConcept").get("author");
            if (authorshipNode != null)
                model.addAttribute("authorship", authorshipNode.asText());

            //taxonomic information
            JsonNode node2 = om.readTree(new URL("http://bie.ala.org.au/ws/species/" + guid + ".json"));
            JsonNode classificationNode = node2.get("classification");
            model.addAttribute("kingdom",
                    StringUtils.capitalize(classificationNode.get("kingdom").asText().toLowerCase()));
            model.addAttribute("phylum",
                    StringUtils.capitalize(classificationNode.get("phylum").asText().toLowerCase()));
            model.addAttribute("clazz",
                    StringUtils.capitalize(classificationNode.get("clazz").asText().toLowerCase()));
            model.addAttribute("order",
                    StringUtils.capitalize(classificationNode.get("order").asText().toLowerCase()));
            model.addAttribute("family",
                    StringUtils.capitalize(classificationNode.get("family").asText().toLowerCase()));
            model.addAttribute("genus", classificationNode.get("genus").asText());

            JsonNode taxonNameNode = node2.get("taxonName");
            if (taxonNameNode != null && taxonNameNode.get("specificEpithet") != null) {
                model.addAttribute("specificEpithet", taxonNameNode.get("specificEpithet").asText());
            }
        }

        SpatialSearchRequestParams searchParams = new SpatialSearchRequestParams();
        searchParams.setQ(newQuery);
        searchParams.setFacets(new String[] { "data_resource" });
        searchParams.setPageSize(0);
        List<FacetResultDTO> facets = searchDAO.getFacetCounts(searchParams);
        model.addAttribute("query", newQuery); //need a facet on data providers
        model.addAttribute("dataProviders", facets.get(0).getFieldResult()); //need a facet on data providers
        return "metadata/mcp";
    }

    @RequestMapping(value = { "/ogc/getFeatureInfo" }, method = RequestMethod.GET)
    public String getFeatureInfo(
            @RequestParam(value = "CQL_FILTER", required = false, defaultValue = "") String cql_filter,
            @RequestParam(value = "ENV", required = false, defaultValue = "") String env,
            @RequestParam(value = "BBOX", required = true, defaultValue = "0,-90,180,0") String bboxString,
            @RequestParam(value = "WIDTH", required = true, defaultValue = "256") Integer width,
            @RequestParam(value = "HEIGHT", required = true, defaultValue = "256") Integer height,
            @RequestParam(value = "STYLES", required = false, defaultValue = "") String styles,
            @RequestParam(value = "SRS", required = false, defaultValue = "") String srs,
            @RequestParam(value = "QUERY_LAYERS", required = false, defaultValue = "") String queryLayers,
            @RequestParam(value = "X", required = true, defaultValue = "0") Double x,
            @RequestParam(value = "Y", required = true, defaultValue = "0") Double y, HttpServletRequest request,
            HttpServletResponse response, Model model) throws Exception {

        logger.debug("WMS - GetFeatureInfo requested for: " + queryLayers);

        if ("EPSG:4326".equals(srs))
            bboxString = convertBBox4326To900913(bboxString); // to work around a UDIG bug

        WmsEnv vars = new WmsEnv(env, styles);
        double[] mbbox = new double[4];
        double[] bbox = new double[4];
        double[] pbbox = new double[4];
        double[] tilebbox = new double[4];
        int size = vars.size + (vars.highlight != null ? HIGHLIGHT_RADIUS * 2 + (int) (vars.size * 0.2) : 0) + 5; //bounding box buffer

        //what is the size of the dot in degrees
        double resolution = getBBoxes(bboxString, width, height, size, vars.uncertainty, mbbox, bbox, pbbox,
                tilebbox);

        //resolution should be a value < 1
        PointType pointType = getPointTypeForDegreesPerPixel(resolution);

        double longitude = bbox[0] + (((bbox[2] - bbox[0]) / width) * x);
        double latitude = bbox[3] - (((bbox[3] - bbox[1]) / height) * y);

        //round to the correct point size
        double roundedLongitude = pointType.roundToPointType(longitude);
        double roundedLatitude = pointType.roundToPointType(latitude);

        //get the pixel size of the circles
        double minLng = pointType.roundDownToPointType(roundedLongitude - (pointType.getValue() * 2 * (size + 3)));
        double maxLng = pointType.roundUpToPointType(roundedLongitude + (pointType.getValue() * 2 * (size + 3)));
        double minLat = pointType.roundDownToPointType(roundedLatitude - (pointType.getValue() * 2 * (size + 3)));
        double maxLat = pointType.roundUpToPointType(roundedLatitude + (pointType.getValue() * 2 * (size + 3)));

        //do the SOLR query
        SpatialSearchRequestParams requestParams = new SpatialSearchRequestParams();
        String q = convertLayersParamToQ(queryLayers);
        requestParams.setQ(convertLayersParamToQ(queryLayers)); //need to derive this from the layer name
        logger.debug("WMS GetFeatureInfo for " + queryLayers + ", longitude:[" + minLng + " TO " + maxLng
                + "],  latitude:[" + minLat + " TO " + maxLat + "]");

        String[] fqs = new String[] { "longitude:[" + minLng + " TO " + maxLng + "]",
                "latitude:[" + minLat + " TO " + maxLat + "]" };
        requestParams.setFq(fqs);
        //requestParams.setFq(new String[]{"point-"+pointType.getValue()+":"+roundedLatitude+","+roundedLongitude});
        requestParams.setFacet(false);

        //TODO: paging
        SolrDocumentList sdl = searchDAO.findByFulltext(requestParams);
        //send back the results.
        String body = "";
        if (sdl != null && sdl.size() > 0) {
            SolrDocument doc = sdl.get(0);
            model.addAttribute("record", doc.getFieldValueMap());
            model.addAttribute("totalRecords", sdl.getNumFound());
        }

        model.addAttribute("uriUrl",
                "http://biocache.ala.org.au/occurrences/search?q=" + URLEncoder.encode(q, "UTF-8") + "&fq="
                        + URLEncoder.encode(fqs[0], "UTF-8") + "&fq=" + URLEncoder.encode(fqs[1], "UTF-8"));

        model.addAttribute("pointType", pointType.name());
        model.addAttribute("minLng", minLng);
        model.addAttribute("maxLng", maxLng);
        model.addAttribute("minLat", minLat);
        model.addAttribute("maxLat", maxLat);
        model.addAttribute("latitudeClicked", latitude);
        model.addAttribute("longitudeClicked", longitude);

        return "metadata/getFeatureInfo";
    }

    String convertLayersParamToQ(String layers) {
        if (StringUtils.trimToNull(layers) != null) {
            String[] parts = layers.split(",");
            String[] formattedParts = new String[parts.length];
            int i = 0;
            for (String part : parts) {
                if (part.contains(":")) {
                    formattedParts[i] = part.replace('_', ' ').replace(":", ":\"") + "\"";
                } else if (part.startsWith("\"")) {
                    formattedParts[i] = "\"" + part + "\"";
                } else {
                    formattedParts[i] = part;
                }
                i++;
            }
            return StringUtils.join(formattedParts, " OR ");
        } else {
            return null;
        }
    }

    @RequestMapping(value = { "/ogc/legendGraphic" }, method = RequestMethod.GET)
    public void getLegendGraphic(@RequestParam(value = "ENV", required = false, defaultValue = "") String env,
            @RequestParam(value = "STYLE", required = false, defaultValue = "8b0000;opacity=1;size=5") String style,
            @RequestParam(value = "WIDTH", required = false, defaultValue = "30") Integer width,
            @RequestParam(value = "HEIGHT", required = false, defaultValue = "20") Integer height,
            HttpServletRequest request, HttpServletResponse response) throws Exception {

        try {
            if (StringUtils.trimToNull(env) == null && StringUtils.trimToNull(style) == null) {
                style = "8b0000;opacity=1;size=5";
            }

            WmsEnv wmsEnv = new WmsEnv(env, style);
            BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
            Graphics2D g = (Graphics2D) img.getGraphics();
            int size = width > height ? height : width;
            Paint fill = new Color(wmsEnv.colour | wmsEnv.alpha << 24);
            g.setPaint(fill);
            g.fillOval(0, 0, size, size);
            OutputStream out = response.getOutputStream();
            logger.debug("WMS - GetLegendGraphic requested : " + request.getQueryString());
            response.setContentType("image/png");
            ImageIO.write(img, "png", out);
            out.close();
        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }
    }

    /**
     * Returns a get capabilities response by default.
     *
     * @param requestParams
     * @param cql_filter
     * @param env
     * @param srs
     * @param styles
     * @param style
     * @param bboxString
     * @param width
     * @param height
     * @param cache
     * @param requestString
     * @param outlinePoints
     * @param outlineColour
     * @param layers
     * @param query
     * @param filterQueries
     * @param x
     * @param y
     * @param spatiallyValidOnly
     * @param marineOnly
     * @param terrestrialOnly
     * @param limitToFocus
     * @param useSpeciesGroups
     * @param request
     * @param response
     * @param model
     * @throws Exception
     */
    @RequestMapping(value = { "/ogc/ows", "/ogc/capabilities" }, method = RequestMethod.GET)
    public void getCapabilities(SpatialSearchRequestParams requestParams,
            @RequestParam(value = "CQL_FILTER", required = false, defaultValue = "") String cql_filter,
            @RequestParam(value = "ENV", required = false, defaultValue = "") String env,
            @RequestParam(value = "SRS", required = false, defaultValue = "EPSG:900913") String srs, //default to google mercator
            @RequestParam(value = "STYLES", required = false, defaultValue = "") String styles,
            @RequestParam(value = "STYLE", required = false, defaultValue = "") String style,
            @RequestParam(value = "BBOX", required = false, defaultValue = "") String bboxString,
            @RequestParam(value = "WIDTH", required = false, defaultValue = "256") Integer width,
            @RequestParam(value = "HEIGHT", required = false, defaultValue = "256") Integer height,
            @RequestParam(value = "CACHE", required = false, defaultValue = "off") String cache,
            @RequestParam(value = "REQUEST", required = false, defaultValue = "") String requestString,
            @RequestParam(value = "OUTLINE", required = false, defaultValue = "false") boolean outlinePoints,
            @RequestParam(value = "OUTLINECOLOUR", required = false, defaultValue = "0x000000") String outlineColour,
            @RequestParam(value = "LAYERS", required = false, defaultValue = "") String layers,
            @RequestParam(value = "q", required = false, defaultValue = "*:*") String query,
            @RequestParam(value = "fq", required = false) String[] filterQueries,
            @RequestParam(value = "X", required = true, defaultValue = "0") Double x,
            @RequestParam(value = "Y", required = true, defaultValue = "0") Double y,
            @RequestParam(value = "spatiallyValidOnly", required = false, defaultValue = "true") boolean spatiallyValidOnly,
            @RequestParam(value = "marineSpecies", required = false, defaultValue = "false") boolean marineOnly,
            @RequestParam(value = "terrestrialSpecies", required = false, defaultValue = "false") boolean terrestrialOnly,
            @RequestParam(value = "limitToFocus", required = false, defaultValue = "true") boolean limitToFocus,
            @RequestParam(value = "useSpeciesGroups", required = false, defaultValue = "false") boolean useSpeciesGroups,
            HttpServletRequest request, HttpServletResponse response, Model model) throws Exception {

        if ("GetMap".equalsIgnoreCase(requestString)) {
            generateWmsTile(requestParams, cql_filter, env, srs, styles, bboxString, width, height, cache,
                    requestString, outlinePoints, outlineColour, layers, null, request, response);
            return;
        }

        if ("GetLegendGraphic".equalsIgnoreCase(requestString)) {
            getLegendGraphic(env, style, 30, 20, request, response);
            return;
        }

        if ("GetFeatureInfo".equalsIgnoreCase(requestString)) {
            getFeatureInfo(cql_filter, env, bboxString, width, height, styles, srs, layers, x, y, request, response,
                    model);
            return;
        }

        //add the get capabilities request

        response.setContentType("text/xml");
        response.setHeader("Content-Description", "File Transfer");
        response.setHeader("Content-Disposition", "attachment; filename=GetCapabilities.xml");
        response.setHeader("Content-Transfer-Encoding", "binary");
        try {
            //webservicesRoot
            String biocacheServerUrl = request.getSession().getServletContext().getInitParameter("webservicesRoot");
            PrintWriter writer = response.getWriter();
            writer.write("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
                    + "<!DOCTYPE WMT_MS_Capabilities SYSTEM \"http://spatial.ala.org.au/geoserver/schemas/wms/1.1.1/WMS_MS_Capabilities.dtd\">\n"
                    + "<WMT_MS_Capabilities version=\"1.1.1\" updateSequence=\"28862\">\n" + "  <Service>\n"
                    + "    <Name>OGC:WMS</Name>\n" + "    <Title>" + organizationName
                    + "(WMS) - Species occurrences</Title>\n"
                    + "    <Abstract>WMS services for species occurrences.</Abstract>\n" + "    <KeywordList>\n"
                    + "      <Keyword>WMS</Keyword>\n" + "      <Keyword>Species occurrence data</Keyword>\n"
                    + "      <Keyword>ALA</Keyword>\n" + "      <Keyword>CRIS</Keyword>\n" + "    </KeywordList>\n"
                    + "    <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + biocacheServerUrl + "/ogc/wms\"/>\n" + "    <ContactInformation>\n"
                    + "      <ContactPersonPrimary>\n" + "        <ContactPerson>ALA Support</ContactPerson>\n"
                    + "        <ContactOrganization>" + organizationName + "</ContactOrganization>\n"
                    + "      </ContactPersonPrimary>\n"
                    + "      <ContactPosition>Support Manager</ContactPosition>\n" + "      <ContactAddress>\n"
                    + "        <AddressType></AddressType>\n" + "        <Address/>\n" + "        <City>" + orgCity
                    + "</City>\n" + "        <StateOrProvince>" + orgStateProvince + "</StateOrProvince>\n"
                    + "        <PostCode>" + orgPostcode + "</PostCode>\n" + "        <Country>" + orgCountry
                    + "</Country>\n" + "      </ContactAddress>\n" + "      <ContactVoiceTelephone>" + orgPhone
                    + "</ContactVoiceTelephone>\n" + "      <ContactFacsimileTelephone>" + orgFax
                    + "</ContactFacsimileTelephone>\n" + "      <ContactElectronicMailAddress>" + orgEmail
                    + "</ContactElectronicMailAddress>\n" + "    </ContactInformation>\n"
                    + "    <Fees>NONE</Fees>\n" + "    <AccessConstraints>NONE</AccessConstraints>\n"
                    + "  </Service>\n" + "  <Capability>\n" + "    <Request>\n" + "      <GetCapabilities>\n"
                    + "        <Format>application/vnd.ogc.wms_xml</Format>\n" + "        <DCPType>\n"
                    + "          <HTTP>\n" + "            <Get>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/capabilities?SERVICE=WMS&amp;\"/>\n" + "            </Get>\n"
                    + "            <Post>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/capabilities?SERVICE=WMS&amp;\"/>\n" + "            </Post>\n"
                    + "          </HTTP>\n" + "        </DCPType>\n" + "      </GetCapabilities>\n"
                    + "      <GetMap>\n" + "        <Format>image/png</Format>\n" + "        <DCPType>\n"
                    + "          <HTTP>\n" + "            <Get>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/wms/reflect?SERVICE=WMS&amp;OUTLINE=TRUE&amp;\"/>\n"
                    + "            </Get>\n" + "          </HTTP>\n" + "        </DCPType>\n" + "      </GetMap>\n"
                    + "      <GetFeatureInfo>\n" + "        <Format>text/html</Format>\n" + "        <DCPType>\n"
                    + "          <HTTP>\n" + "            <Get>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/getFeatureInfo\"/>\n" + "            </Get>\n" + "            <Post>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/getFeatureInfo\"/>\n" + "            </Post>\n" + "          </HTTP>\n"
                    + "        </DCPType>\n" + "      </GetFeatureInfo>\n" + "      <GetLegendGraphic>\n"
                    + "        <Format>image/png</Format>\n" + "        <Format>image/jpeg</Format>\n"
                    + "        <Format>image/gif</Format>\n" + "        <DCPType>\n" + "          <HTTP>\n"
                    + "            <Get>\n"
                    + "              <OnlineResource xmlns:xlink=\"http://www.w3.org/1999/xlink\" xlink:type=\"simple\" xlink:href=\""
                    + baseWsUrl + "/ogc/legendGraphic\"/>\n" + "            </Get>\n" + "          </HTTP>\n"
                    + "        </DCPType>\n" + "      </GetLegendGraphic>\n" + "    </Request>\n"
                    + "    <Exception>\n" + "      <Format>application/vnd.ogc.se_xml</Format>\n"
                    + "      <Format>application/vnd.ogc.se_inimage</Format>\n" + "    </Exception>\n"
                    + "    <Layer>\n" + "      <Title>" + organizationName
                    + " - Species occurrence layers</Title>\n" + "      <Abstract>Custom WMS services for "
                    + organizationName + " species occurrences</Abstract>\n" + "      <SRS>EPSG:900913</SRS>\n"
                    + "      <SRS>EPSG:4326</SRS>\n"
                    + "     <LatLonBoundingBox minx=\"-179.9\" miny=\"-89.9\" maxx=\"179.9\" maxy=\"89.9\"/>\n");

            writer.write(generateStylesForPoints());

            if (spatiallyValidOnly) {
                filterQueries = org.apache.commons.lang3.ArrayUtils.add(filterQueries, "geospatial_kosher:true");
            }

            if (marineOnly) {
                filterQueries = org.apache.commons.lang3.ArrayUtils.add(filterQueries,
                        "species_habitats:Marine OR species_habitats:\"Marine and Non-marine\"");
            }

            if (terrestrialOnly) {
                filterQueries = org.apache.commons.lang3.ArrayUtils.add(filterQueries,
                        "species_habitats:\"Non-marine\" OR species_habitats:Limnetic");
            }

            if (limitToFocus) {
                //TODO retrieve focus from config file
                filterQueries = org.apache.commons.lang3.ArrayUtils.add(filterQueries,
                        "latitude:[-89 TO -8] AND longitude:[100 TO 165]");
            }

            query = searchUtils.convertRankAndName(query);
            logger.debug("GetCapabilities query in use: " + query);

            if (useSpeciesGroups) {
                taxonDAO.extractBySpeciesGroups(baseWsUrl + "/ogc/getMetadata", query, filterQueries, writer);
            } else {
                taxonDAO.extractHierarchy(baseWsUrl + "/ogc/getMetadata", query, filterQueries, writer);
            }

            writer.write("</Layer></Capability></WMT_MS_Capabilities>\n");

        } catch (Exception e) {
            logger.error(e.getMessage(), e);
        }
    }

    public String generateStylesForPoints() {
        //need a better listings of colours
        String[] sizes = new String[] { "5", "10", "2" };
        String[] sizesNames = new String[] { "medium", "large", "small" };
        String[] opacities = new String[] { "0.5", "1", "0.2" };
        String[] opacitiesNames = new String[] { "medium", "opaque", "transparency" };
        StringBuffer sb = new StringBuffer();
        int colorIdx = 0;
        int sizeIdx = 0;
        int opIdx = 0;
        for (String color : colorsNames) {
            for (String size : sizes) {
                for (String opacity : opacities) {
                    sb.append("<Style>\n" + "<Name>" + colorsCodes[colorIdx] + ";opacity=" + opacity + ";size="
                            + size + "</Name> \n" + "<Title>" + color + ";opacity=" + opacitiesNames[opIdx]
                            + ";size=" + sizesNames[sizeIdx] + "</Title> \n" + "</Style>\n");
                    opIdx++;
                }
                opIdx = 0;
                sizeIdx++;
            }
            sizeIdx = 0;
            colorIdx++;
        }
        return sb.toString();
    }

    /**
     * WMS service for webportal.
     *
     * @param cql_filter q value.
     * @param env        ';' delimited field:value pairs.  See Env
     * @param bboxString
     * @param width
     * @param height
     * @param cache      'on' = use cache, 'off' = do not use cache this
     *                   also removes any related cache data.
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/wms/reflect", "/ogc/wms/reflect",
            "/mapping/wms/reflect" }, method = RequestMethod.GET)
    public void generateWmsTile(SpatialSearchRequestParams requestParams,
            @RequestParam(value = "CQL_FILTER", required = false, defaultValue = "") String cql_filter,
            @RequestParam(value = "ENV", required = false, defaultValue = "") String env,
            @RequestParam(value = "SRS", required = false, defaultValue = "EPSG:900913") String srs, //default to google mercator
            @RequestParam(value = "STYLES", required = false, defaultValue = "") String styles,
            @RequestParam(value = "BBOX", required = true, defaultValue = "") String bboxString,
            @RequestParam(value = "WIDTH", required = true, defaultValue = "256") Integer width,
            @RequestParam(value = "HEIGHT", required = true, defaultValue = "256") Integer height,
            @RequestParam(value = "CACHE", required = true, defaultValue = "off") String cache,
            @RequestParam(value = "REQUEST", required = true, defaultValue = "") String requestString,
            @RequestParam(value = "OUTLINE", required = true, defaultValue = "false") boolean outlinePoints,
            @RequestParam(value = "OUTLINECOLOUR", required = true, defaultValue = "0x000000") String outlineColour,
            @RequestParam(value = "LAYERS", required = false, defaultValue = "") String layers,
            @RequestParam(value = "HQ", required = false) String[] hqs, HttpServletRequest request,
            HttpServletResponse response) throws Exception {

        //Some WMS clients are ignoring sections of the GetCapabilities....
        if ("GetLegendGraphic".equalsIgnoreCase(requestString)) {
            getLegendGraphic(env, styles, 30, 20, request, response);
            return;
        }

        Set<Integer> hq = new HashSet<Integer>();
        if (hqs != null && hqs.length > 0) {
            for (String h : hqs) {
                hq.add(Integer.parseInt(h));
            }
        }

        logger.debug("WMS tile: " + request.getQueryString());

        response.setHeader("Cache-Control", "max-age=86400"); //age == 1 day
        response.setContentType("image/png"); //only png images generated

        boolean is4326 = false;
        WmsEnv vars = new WmsEnv(env, styles);
        double[] mbbox = new double[4];
        double[] bbox = new double[4];
        double[] pbbox = new double[4];
        double[] tilebbox = new double[4];
        int size = vars.size + (vars.highlight != null ? HIGHLIGHT_RADIUS * 2 + (int) (vars.size * 0.2) : 0) + 5; //bounding box buffer

        double resolution;
        if ("EPSG:4326".equals(srs)) {
            is4326 = true;
            //bboxString = convertBBox4326To900913(bboxString);    // to work around a UDIG bug

            resolution = getBBoxes4326(bboxString, width, height, size, vars.uncertainty, mbbox, bbox, pbbox,
                    tilebbox);
        } else {
            resolution = getBBoxes(bboxString, width, height, size, vars.uncertainty, mbbox, bbox, pbbox, tilebbox);
        }

        PointType pointType = getPointTypeForDegreesPerPixel(resolution);
        logger.debug("Rendering: " + pointType.name());

        String q = "";

        //CQL Filter takes precedence of the layer
        if (StringUtils.trimToNull(cql_filter) != null) {
            q = getQ(cql_filter);
        } else if (StringUtils.trimToNull(layers) != null && !"ALA:Occurrences".equalsIgnoreCase(layers)) {
            q = convertLayersParamToQ(layers);
        }

        String[] boundingBoxFqs = new String[2];
        boundingBoxFqs[0] = String.format("longitude:[%f TO %f]", bbox[0], bbox[2]);
        boundingBoxFqs[1] = String.format("latitude:[%f TO %f]", bbox[1], bbox[3]);

        int pointWidth = vars.size * 2;
        double width_mult = (width / (pbbox[2] - pbbox[0]));
        double height_mult = (height / (pbbox[1] - pbbox[3]));

        //build request
        if (q.length() > 0) {
            requestParams.setQ(q);
        } else {
            q = requestParams.getQ();
        }

        //bounding box test (q must be 'qid:' + number)
        if (q.startsWith("qid:")) {
            double[] queryBBox = ParamsCache.get(Long.parseLong(q.substring(4))).getBbox();
            if (queryBBox != null && (queryBBox[0] > bbox[2] || queryBBox[2] < bbox[0] || queryBBox[1] > bbox[3]
                    || queryBBox[3] < bbox[1])) {
                displayBlankImage(response);
                return;
            }
        }

        String[] originalFqs = getFq(requestParams);

        //get from cache
        WMSTile wco = null;
        if (WMSCache.isEnabled() && cache.equalsIgnoreCase("on")) {
            wco = getWMSCacheObject(vars, pointType, requestParams, bbox);
        } else if (!cache.equalsIgnoreCase("on")) {
            WMSCache.remove(requestParams.getUrlParams(), vars.colourMode, pointType);
        }

        ImgObj imgObj = null;
        if (wco == null) {
            imgObj = wmsUncached(requestParams, vars, pointType, pbbox, mbbox, width, height, width_mult,
                    height_mult, pointWidth, originalFqs, hq, boundingBoxFqs, outlinePoints, outlineColour,
                    response, is4326, tilebbox);
        } else {
            imgObj = wmsCached(wco, requestParams, vars, pointType, pbbox, bbox, mbbox, width, height, width_mult,
                    height_mult, pointWidth, originalFqs, hq, boundingBoxFqs, outlinePoints, outlineColour,
                    response, is4326, tilebbox);
        }

        if (imgObj != null && imgObj.g != null) {
            imgObj.g.dispose();
            try {
                ServletOutputStream outStream = response.getOutputStream();
                ImageIO.write(imgObj.img, "png", outStream);
                outStream.flush();
                outStream.close();
            } catch (Exception e) {
                logger.error("Unable to write image", e);
            }
        } else {
            displayBlankImage(response);
        }
    }

    /**
     * Method that produces the downloadable map integrated in AVH/OZCAM/Biocache.
     *
     * @param requestParams
     * @param format
     * @param extents
     * @param widthMm
     * @param pointRadiusMm
     * @param pradiusPx
     * @param pointColour
     * @param pointOpacity
     * @param baselayer
     * @param scale
     * @param dpi
     * @param outlinePoints
     * @param outlineColour
     * @param fileName
     * @param request
     * @param response
     * @throws Exception
     */
    @RequestMapping(value = { "/webportal/wms/image", "/mapping/wms/image" }, method = RequestMethod.GET)
    public void generatePublicationMap(SpatialSearchRequestParams requestParams,
            @RequestParam(value = "format", required = false, defaultValue = "jpg") String format,
            @RequestParam(value = "extents", required = true) String extents,
            @RequestParam(value = "widthmm", required = false, defaultValue = "60") Double widthMm,
            @RequestParam(value = "pradiusmm", required = false, defaultValue = "2") Double pointRadiusMm,
            @RequestParam(value = "pradiuspx", required = false) Integer pradiusPx,
            @RequestParam(value = "pcolour", required = false, defaultValue = "FF0000") String pointColour,
            @RequestParam(value = "popacity", required = false, defaultValue = "0.8") Double pointOpacity,
            @RequestParam(value = "baselayer", required = false, defaultValue = "world") String baselayer,
            @RequestParam(value = "scale", required = false, defaultValue = "off") String scale,
            @RequestParam(value = "dpi", required = false, defaultValue = "300") Integer dpi,
            @RequestParam(value = "outline", required = true, defaultValue = "false") boolean outlinePoints,
            @RequestParam(value = "outlineColour", required = true, defaultValue = "#000000") String outlineColour,
            @RequestParam(value = "fileName", required = false) String fileName, HttpServletRequest request,
            HttpServletResponse response) throws Exception {

        String[] bb = extents.split(",");

        double long1 = Double.parseDouble(bb[0]);
        double lat1 = Double.parseDouble(bb[1]);
        double long2 = Double.parseDouble(bb[2]);
        double lat2 = Double.parseDouble(bb[3]);

        if (lat1 <= -90) {
            lat1 = -89.999;
        }
        if (lat2 >= 90) {
            lat2 = 89.999;
        }

        int pminx = convertLngToPixel(long1);
        int pminy = convertLatToPixel(lat1);
        int pmaxx = convertLngToPixel(long2);
        int pmaxy = convertLatToPixel(lat2);

        int width = (int) ((dpi / 25.4) * widthMm);
        int height = (int) Math.round(width * ((pminy - pmaxy) / (double) (pmaxx - pminx)));

        if (height * width > MAX_IMAGE_PIXEL_COUNT) {
            String errorMessage = "Image size in pixels " + width + "x" + height + " exceeds "
                    + MAX_IMAGE_PIXEL_COUNT + " pixels.  Make the image smaller";
            response.sendError(response.SC_NOT_ACCEPTABLE, errorMessage);
            throw new Exception(errorMessage);
        }

        int pointSize = -1;
        if (pradiusPx != null) {
            pointSize = (int) pradiusPx;
        } else {
            pointSize = (int) ((dpi / 25.4) * pointRadiusMm);
        }

        double[] boundingBox = transformBbox4326To900913(Double.parseDouble(bb[0]), Double.parseDouble(bb[1]),
                Double.parseDouble(bb[2]), Double.parseDouble(bb[3]));

        //"http://biocache.ala.org.au/ws/webportal/wms/reflect?
        //q=macropus&ENV=color%3Aff0000%3Bname%3Acircle%3Bsize%3A3%3Bopacity%3A1
        //&BBOX=12523443.0512,-2504688.2032,15028131.5936,0.33920000120997&WIDTH=256&HEIGHT=256");
        String speciesAddress = baseWsUrl + "/ogc/wms/reflect?" + "ENV=color%3A" + pointColour
                + "%3Bname%3Acircle%3Bsize%3A" + pointSize + "%3Bopacity%3A" + pointOpacity + "&BBOX="
                + boundingBox[0] + "," + boundingBox[1] + "," + boundingBox[2] + "," + boundingBox[3] + "&WIDTH="
                + width + "&HEIGHT=" + height + "&OUTLINE=" + outlinePoints + "&OUTLINECOLOUR=" + outlineColour
                + "&" + request.getQueryString();

        URL speciesURL = new URL(speciesAddress);
        BufferedImage speciesImage = ImageIO.read(speciesURL);

        //"http://spatial.ala.org.au/geoserver/wms/reflect?
        //LAYERS=ALA%3Aworld&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=
        //&FORMAT=image%2Fjpeg&SRS=EPSG%3A900913&BBOX=12523443.0512,-1252343.932,13775787.3224,0.33920000004582&WIDTH=256&HEIGHT=256"
        String layout = "";
        if (!scale.equals("off")) {
            layout += "layout:scale";
        }
        String basemapAddress = geoserverUrl + "/wms/reflect?" + "LAYERS=ALA%3A" + baselayer
                + "&SERVICE=WMS&VERSION=1.1.1&REQUEST=GetMap&STYLES=" + "&FORMAT=image%2Fpng&SRS=EPSG%3A900913" //specify the mercator projection
                + "&BBOX=" + boundingBox[0] + "," + boundingBox[1] + "," + boundingBox[2] + "," + boundingBox[3]
                + "&WIDTH=" + width + "&HEIGHT=" + height + "&OUTLINE=" + outlinePoints + "&format_options=dpi:"
                + dpi + ";" + layout;

        BufferedImage basemapImage = ImageIO.read(new URL(basemapAddress));

        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        Graphics2D combined = (Graphics2D) img.getGraphics();

        combined.drawImage(basemapImage, 0, 0, Color.WHITE, null);
        //combined.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, pointOpacity.floatValue()));
        combined.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 1.0f));
        combined.drawImage(speciesImage, null, 0, 0);
        combined.dispose();

        //if filename supplied, force a download
        if (fileName != null) {
            response.setContentType("application/octet-stream");
            response.setHeader("Content-Description", "File Transfer");
            response.setHeader("Content-Disposition", "attachment; filename=" + fileName);
            response.setHeader("Content-Transfer-Encoding", "binary");
        } else if (format.equalsIgnoreCase("png")) {
            response.setContentType("image/png");
        } else {
            response.setContentType("image/jpeg");
        }

        if (format.equalsIgnoreCase("png")) {
            OutputStream os = response.getOutputStream();
            ImageIO.write(img, format, os);
            os.close();
        } else {
            //handle jpeg + BufferedImage.TYPE_INT_ARGB
            BufferedImage img2;
            Graphics2D c2;
            (c2 = (Graphics2D) (img2 = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)).getGraphics())
                    .drawImage(img, 0, 0, Color.WHITE, null);
            c2.dispose();
            OutputStream os = response.getOutputStream();
            ImageIO.write(img2, format, os);
            os.close();
        }
    }

    /**
     *
     * @return
     * @throws Exception
     */
    private ImgObj wmsCached(WMSTile wco, SpatialSearchRequestParams requestParams, WmsEnv vars,
            PointType pointType, double[] pbbox, double[] bbox, double[] mbbox, int width, int height,
            double width_mult, double height_mult, int pointWidth, String[] originalFqs, Set<Integer> hq,
            String[] boundingBoxFqs, boolean outlinePoints, String outlineColour, HttpServletResponse response,
            boolean is4326, double[] tilebbox) throws Exception {

        ImgObj imgObj = null;

        //grid setup
        int divs = 16; //number of x & y divisions in the WIDTH/HEIGHT
        int[][] gridCounts = new int[divs][divs];
        int xstep = 256 / divs;
        int ystep = 256 / divs;
        double grid_width_mult = (width / (pbbox[2] - pbbox[0])) / (width / divs);
        double grid_height_mult = (height / (pbbox[1] - pbbox[3])) / (height / divs);

        int x, y;

        //if not transparent and zero size, render dots
        if (vars.alpha > 0 && vars.size > 0) {
            List<float[]> points = wco.getPoints();
            List<int[]> counts = wco.getCounts();
            List<Integer> pColour = wco.getColours();
            if (pColour.size() == 1 && vars.colourMode.equals("-1")) {
                pColour.set(0, vars.colour | (vars.alpha << 24));
            }

            //initialise the image object
            imgObj = ImgObj.create(width, height);

            for (int j = 0; j < points.size(); j++) {

                if (hq != null && hq.contains(j)) {
                    //dont render these points
                    continue;
                }

                float[] ps = points.get(j);

                if (ps == null || ps.length == 0) {
                    continue;
                }

                //for 4326
                double top = tilebbox[3];
                double bottom = tilebbox[1];
                double left = tilebbox[0];
                double right = tilebbox[2];

                if (vars.colourMode.equals("grid")) {
                    //render grids
                    int[] count = counts.get(j);

                    //populate grid
                    for (int i = 0; i < ps.length; i += 2) {
                        float lng = ps[i];
                        float lat = ps[i + 1];
                        if (lng >= bbox[0] && lng <= bbox[2] && lat >= bbox[1] && lat <= bbox[3]) {

                            if (is4326) {
                                x = convertLngToPixel4326(lng, left, right, width);
                                y = convertLatToPixel4326(lat, top, bottom, height);
                            } else {
                                x = (int) ((convertLngToPixel(lng) - pbbox[0]) * grid_width_mult);
                                y = (int) ((convertLatToPixel(lat) - pbbox[3]) * grid_height_mult);
                            }

                            if (x >= 0 && x < divs && y >= 0 && y < divs) {
                                gridCounts[x][y] += count[i / 2];
                            }
                        }
                    }
                } else {
                    //render points
                    Paint currentFill = new Color(pColour.get(j), true);
                    imgObj.g.setPaint(currentFill);
                    Color oColour = Color.decode(outlineColour);
                    for (int i = 0; i < ps.length; i += 2) {
                        float lng = ps[i];
                        float lat = ps[i + 1];
                        if (lng >= bbox[0] && lng <= bbox[2] && lat >= bbox[1] && lat <= bbox[3]) {

                            if (is4326) {
                                x = convertLngToPixel4326(lng, left, right, width);
                                y = convertLatToPixel4326(lat, top, bottom, height);
                            } else {
                                x = (int) ((convertLngToPixel(lng) - pbbox[0]) * width_mult);
                                y = (int) ((convertLatToPixel(lat) - pbbox[3]) * height_mult);
                            }

                            imgObj.g.fillOval(x - vars.size, y - vars.size, pointWidth, pointWidth);
                            if (outlinePoints) {
                                imgObj.g.setPaint(oColour);
                                imgObj.g.drawOval(x - vars.size, y - vars.size, pointWidth, pointWidth);
                                imgObj.g.setPaint(currentFill);
                            }
                        }
                    }
                }
            }
        }

        //no points
        if (imgObj == null) {
            if (vars.highlight == null) {
                displayBlankImage(response);
                return null;
            }
        } else if (vars.colourMode.equals("grid")) {
            //draw grid
            for (x = 0; x < divs; x++) {
                for (y = 0; y < divs; y++) {
                    int v = gridCounts[x][y];
                    if (v > 0) {
                        if (v > 500) {
                            v = 500;
                        }
                        int colour = (((500 - v) / 2) << 8) | (vars.alpha << 24) | 0x00FF0000;
                        imgObj.g.setColor(new Color(colour));
                        imgObj.g.fillRect(x * xstep, y * ystep, xstep, ystep);
                    }
                }
            }
        } else {
            drawUncertaintyCircles(requestParams, vars, height, width, pbbox, mbbox, width_mult, height_mult,
                    imgObj.g, originalFqs, boundingBoxFqs, is4326, tilebbox);
        }

        //highlight
        if (vars.highlight != null) {
            imgObj = drawHighlight(requestParams, vars, pointType, width, height, pbbox, width_mult, height_mult,
                    imgObj, originalFqs, boundingBoxFqs, is4326, tilebbox);
        }

        return imgObj;
    }

    void drawUncertaintyCircles(SpatialSearchRequestParams requestParams, WmsEnv vars, int height, int width,
            double[] pbbox, double[] mbbox, double width_mult, double height_mult, Graphics2D g,
            String[] originalFqs, String[] boundingBoxFqs, boolean is4326, double[] tilebbox) throws Exception {
        //draw uncertainty circles
        double hmult = (height / (mbbox[3] - mbbox[1]));

        //only draw uncertainty if max radius will be > 1 pixels
        if (vars.uncertainty && MAX_UNCERTAINTY * hmult > 1) {

            //uncertainty colour/fq/radius, [0]=map, [1]=not specified, [2]=too large
            Color[] uncertaintyColours = { new Color(255, 170, 0, vars.alpha), new Color(255, 255, 100, vars.alpha),
                    new Color(50, 255, 50, vars.alpha) };
            //TODO: don't assume MAX_UNCERTAINTY > default_uncertainty
            String[] uncertaintyFqs = {
                    "coordinate_uncertainty:[* TO " + MAX_UNCERTAINTY + "] AND -assertions:uncertaintyNotSpecified",
                    "assertions:uncertaintyNotSpecified", "coordinate_uncertainty:[" + MAX_UNCERTAINTY + " TO *]" };
            double[] uncertaintyR = { -1, MAX_UNCERTAINTY, MAX_UNCERTAINTY };

            String[] fqs = new String[originalFqs.length + 3];
            System.arraycopy(originalFqs, 0, fqs, 3, originalFqs.length);
            fqs[1] = boundingBoxFqs[0];
            fqs[2] = boundingBoxFqs[1];

            requestParams.setPageSize(DEFAULT_PAGE_SIZE);

            for (int j = 0; j < uncertaintyFqs.length; j++) {
                //do not display for [1]=not specified
                if (j == 1) {
                    continue;
                }

                fqs[0] = uncertaintyFqs[j];
                requestParams.setFq(fqs);
                requestParams.setFl("longitude,latitude,coordinate_uncertainty"); //only retrieve longitude and latitude
                requestParams.setFacet(false);

                //TODO: paging
                SolrDocumentList sdl = searchDAO.findByFulltext(requestParams);

                //for 4326
                double top = tilebbox[3];
                double bottom = tilebbox[1];
                double left = tilebbox[0];
                double right = tilebbox[2];

                double lng, lat;
                int x, y;
                int uncertaintyRadius = (int) Math.ceil(uncertaintyR[j] * hmult);
                if (sdl != null && sdl.size() > 0) {
                    g.setColor(uncertaintyColours[j]);
                    for (int i = 0; i < sdl.size(); i++) {
                        if (uncertaintyR[j] < 0) {
                            uncertaintyRadius = (int) Math
                                    .ceil((Double) sdl.get(i).getFieldValue("coordinate_uncertainty") * hmult);
                        }

                        lng = (Double) sdl.get(i).getFieldValue("longitude");
                        lat = (Double) sdl.get(i).getFieldValue("latitude");

                        if (is4326) {
                            x = convertLngToPixel4326(lng, left, right, width);
                            y = convertLatToPixel4326(lat, top, bottom, height);
                        } else {
                            x = (int) ((convertLngToPixel(lng) - pbbox[0]) * width_mult);
                            y = (int) ((convertLatToPixel(lat) - pbbox[3]) * height_mult);
                        }

                        if (uncertaintyRadius > 0) {
                            g.drawOval(x - uncertaintyRadius, y - uncertaintyRadius, uncertaintyRadius * 2,
                                    uncertaintyRadius * 2);
                        } else {
                            g.drawRect(x, y, 1, 1);
                        }
                    }
                }
            }
        }
    }

    ImgObj drawHighlight(SpatialSearchRequestParams requestParams, WmsEnv vars, PointType pointType, int width,
            int height, double[] pbbox, double width_mult, double height_mult, ImgObj imgObj, String[] originalFqs,
            String[] boundingBoxFqs, boolean is4326, double[] tilebbox) throws Exception {
        String[] fqs = new String[originalFqs.length + 3];
        System.arraycopy(originalFqs, 0, fqs, 3, originalFqs.length);
        fqs[0] = vars.highlight;
        fqs[1] = boundingBoxFqs[0];
        fqs[2] = boundingBoxFqs[1];

        requestParams.setFq(fqs);
        List<OccurrencePoint> ps = searchDAO.getFacetPoints(requestParams, pointType);

        if (ps != null && ps.size() > 0) {
            if (imgObj == null || imgObj.img == null) { //when vars.alpha == 0 img is null
                imgObj = ImgObj.create(width, height);
            }

            int highightRadius = vars.size + HIGHLIGHT_RADIUS;
            int highlightWidth = highightRadius * 2;

            imgObj.g.setStroke(new BasicStroke(2));
            imgObj.g.setColor(new Color(255, 0, 0, 255));
            int x, y;

            //for 4326
            double top = tilebbox[3];
            double bottom = tilebbox[1];
            double left = tilebbox[0];
            double right = tilebbox[2];

            for (int i = 0; i < ps.size(); i++) {
                OccurrencePoint pt = ps.get(i);
                float lng = pt.getCoordinates().get(0).floatValue();
                float lat = pt.getCoordinates().get(1).floatValue();

                if (is4326) {
                    x = convertLngToPixel4326(lng, left, right, width);
                    y = convertLatToPixel4326(lat, top, bottom, height);
                } else {
                    x = (int) ((convertLngToPixel(lng) - pbbox[0]) * width_mult);
                    y = (int) ((convertLatToPixel(lat) - pbbox[3]) * height_mult);
                }

                imgObj.g.drawOval(x - highightRadius, y - highightRadius, highlightWidth, highlightWidth);
            }
        }

        return imgObj;
    }

    /**
     * Returns the wms cache object and initialises it if required.
     * @param vars
     * @param pointType
     * @param requestParams
     * @param bbox
     * @return
     * @throws Exception
     */
    WMSTile getWMSCacheObject(WmsEnv vars, PointType pointType, SpatialSearchRequestParams requestParams,
            double[] bbox) throws Exception {
        if (WMSCache.isFull() || !WMSCache.isEnabled()) {
            return null;
        }

        String q = requestParams.getUrlParams();
        WMSTile wco = WMSCache.get(q, vars.colourMode, pointType);
        if (wco.getCached()) {
            return wco;
        } else if (!wco.isCacheable()) {
            return null;
        }

        //build only once
        synchronized (wco) {
            if (wco.getCached()) {
                return wco;
            } else if (!wco.isCacheable()) {
                return null;
            }

            List<LegendItem> colours = (vars.colourMode.equals("-1") || vars.colourMode.equals("grid")) ? null
                    : getColours(requestParams, vars.colourMode);
            int sz = colours == null ? 1 : colours.size() + 1;

            //points count
            SpatialSearchRequestParams r = new SpatialSearchRequestParams();
            r.setQ(requestParams.getQ());
            r.setFq(getFq(requestParams));
            r.setQc(requestParams.getQc());
            r.setPageSize(0);
            r.setFacet(false);
            SolrDocumentList sdl = searchDAO.findByFulltext(r);
            int occurrenceCount = (int) sdl.getNumFound();
            if (!WMSCache.isCachable(wco, occurrenceCount, vars.colourMode.equals("grid"))) {
                return null;
            }

            List<List<OccurrencePoint>> points = new ArrayList<List<OccurrencePoint>>(sz);
            List<Integer> pColour = new ArrayList<Integer>(sz);

            ArrayList<String> forNulls = new ArrayList<String>(sz);
            String[] fqs = null;
            String[] originalFqs = getFq(requestParams);
            if (originalFqs == null || originalFqs.length == 0) {
                fqs = new String[1];
            } else {
                fqs = new String[originalFqs.length + 1];
                System.arraycopy(originalFqs, 0, fqs, 1, originalFqs.length);
            }

            requestParams.setFq(fqs);

            if (colours != null) {
                //get facet points
                for (int i = 0; i < colours.size(); i++) {
                    LegendItem li = colours.get(i);
                    fqs[0] = li.getFq();
                    if (li.getName() == null) {
                        //li.getFq() is of the form "-(...)"
                        forNulls.add(fqs[0].substring(1));
                    } else {
                        if (fqs[0].charAt(0) == '-') {
                            forNulls.add(fqs[0].substring(1));
                        } else {
                            forNulls.add("-" + fqs[0]);
                        }
                    }
                    requestParams.setFq(fqs);
                    points.add(searchDAO.getFacetPoints(requestParams, pointType));
                    pColour.add(li.getColour() | (vars.alpha << 24));
                }
            }
            //get points for occurrences not in colours.
            if (colours == null || colours.isEmpty()) {
                requestParams.setFq(originalFqs); //only filter by bounding box
                points.add(searchDAO.getFacetPoints(requestParams, pointType));
                pColour.add(vars.colour);
            } else if (colours.size() >= colourList.length - 1) {
                fqs = new String[forNulls.size()];
                forNulls.toArray(fqs);
                requestParams.setFq(fqs);
                points.add(searchDAO.getFacetPoints(requestParams, pointType));
                pColour.add(colourList[colourList.length - 1] | (vars.alpha << 24));
            }

            //construct points and their counts
            List<float[]> pointsArrays = new ArrayList<float[]>(points.size());
            for (int i = 0; i < points.size(); i++) {
                List<OccurrencePoint> ops = points.get(i);
                float[] d = new float[ops.size() * 2];
                for (int k = 0, j = 0; k < ops.size(); k++, j += 2) {
                    d[j] = ops.get(k).getCoordinates().get(0).floatValue();
                    d[j + 1] = ops.get(k).getCoordinates().get(1).floatValue();
                }
                pointsArrays.add(d);
            }

            List<int[]> countsArrays = null;
            if (vars.colourMode.equals("grid")) {
                countsArrays = new ArrayList<int[]>(points.size());
                for (int i = 0; i < points.size(); i++) {
                    List<OccurrencePoint> ops = points.get(i);
                    int[] c = new int[ops.size()];
                    for (int k = 0; k < ops.size(); k++) {
                        c[k] = ops.get(k).getCount().intValue();
                    }
                    countsArrays.add(c);
                }
            }

            wco.setBbox(bbox);
            wco.setColourmode(q);
            wco.setColourmode(vars.colourMode);
            wco.setColours(pColour);
            wco.setCounts(countsArrays);
            wco.setPoints(pointsArrays);
            wco.setQuery(q);

            WMSCache.put(q, vars.colourMode, pointType, wco);

            return wco;
        }
    }

    private String[] getFq(SpatialSearchRequestParams requestParams) {
        int requestParamsFqLength = requestParams.getFq() != null ? requestParams.getFq().length : 0;

        String[] qidFq = null;
        int qidFqLength = 0;
        String q = requestParams.getQ();
        if (q.startsWith("qid:")) {
            try {
                qidFq = ParamsCache.get(Long.parseLong(q.substring(4))).getFqs();
                if (qidFq != null) {
                    qidFqLength = qidFq.length;
                }
            } catch (Exception e) {
            }
        }

        if (requestParamsFqLength + qidFqLength == 0) {
            return null;
        }

        String[] allFqs = new String[requestParamsFqLength + qidFqLength];

        if (requestParamsFqLength > 0) {
            System.arraycopy(requestParams.getFq(), 0, allFqs, 0, requestParamsFqLength);
        }

        if (qidFqLength > 0) {
            System.arraycopy(qidFq, 0, allFqs, requestParamsFqLength, qidFqLength);
        }

        return allFqs;
    }

    /**
     * TODO remove code duplicate between wmsUncached and wmsCached
     *
     * @return
     * @throws Exception
     */
    private ImgObj wmsUncached(SpatialSearchRequestParams requestParams, WmsEnv vars, PointType pointType,
            double[] pbbox, double[] mbbox, int width, int height, double width_mult, double height_mult,
            int pointWidth, String[] originalFqs, Set<Integer> hq, String[] boundingBoxFqs, boolean outlinePoints,
            String outlineColour, HttpServletResponse response, boolean is4326, double[] tilebbox)
            throws Exception {

        //colour mapping
        List<LegendItem> colours = (vars.colourMode.equals("-1") || vars.colourMode.equals("grid")) ? null
                : getColours(requestParams, vars.colourMode);
        int sz = colours == null ? 1 : colours.size() + 1;

        List<List<OccurrencePoint>> points = new ArrayList<List<OccurrencePoint>>(sz);
        List<Integer> pColour = new ArrayList<Integer>(sz);

        List<String> forNulls = new ArrayList<String>(sz);
        String[] fqs = null;
        String[] origAndBBoxFqs = null;
        if (originalFqs == null || originalFqs.length == 0) {
            fqs = new String[3];
            fqs[1] = boundingBoxFqs[0];
            fqs[2] = boundingBoxFqs[1];

            origAndBBoxFqs = boundingBoxFqs;
        } else {
            fqs = new String[originalFqs.length + 3];
            System.arraycopy(originalFqs, 0, fqs, 3, originalFqs.length);
            fqs[1] = boundingBoxFqs[0];
            fqs[2] = boundingBoxFqs[1];

            origAndBBoxFqs = new String[originalFqs.length + 2];
            System.arraycopy(originalFqs, 0, origAndBBoxFqs, 2, originalFqs.length);
            origAndBBoxFqs[0] = boundingBoxFqs[0];
            origAndBBoxFqs[1] = boundingBoxFqs[1];
        }

        requestParams.setFq(fqs);

        if (vars.alpha > 0 && vars.size > 0) {
            if (colours != null) {
                //get facet points
                for (int i = 0; i < colours.size(); i++) {
                    LegendItem li = colours.get(i);
                    fqs[0] = li.getFq();
                    if (li.getName() == null) {
                        //li.getFq() is of the form "-(...)"
                        forNulls.add(fqs[0].substring(1));
                    } else {
                        if (fqs[0].charAt(0) == '-') {
                            forNulls.add(fqs[0].substring(1));
                        } else {
                            forNulls.add("-" + fqs[0]);
                        }
                    }
                    requestParams.setFq(fqs);
                    points.add(searchDAO.getFacetPoints(requestParams, pointType));
                    pColour.add(li.getColour() | (vars.alpha << 24));
                }
            }
            //get points for occurrences not in colours.
            if (colours == null || colours.isEmpty()) {
                requestParams.setFq(origAndBBoxFqs); //only filter by bounding box
                points.add(searchDAO.getFacetPoints(requestParams, pointType));
                pColour.add(vars.colour);
            } else if (colours.size() >= colourList.length - 1) {
                fqs = new String[forNulls.size()];
                forNulls.toArray(fqs);

                String[] newFqs = new String[originalFqs.length + forNulls.size()];
                if (originalFqs.length > 0) {
                    System.arraycopy(originalFqs, 0, newFqs, 0, originalFqs.length);
                }
                if (fqs.length > 0) {
                    System.arraycopy(fqs, 0, newFqs, originalFqs.length, fqs.length);
                }
                requestParams.setFq(newFqs);

                points.add(searchDAO.getFacetPoints(requestParams, pointType));
                pColour.add(colourList[colourList.length - 1] | (vars.alpha << 24));
            }
        }

        ImgObj imgObj = null;

        //grid setup
        int divs = 16; //number of x & y divisions in the WIDTH/HEIGHT
        int[][] gridCounts = new int[divs][divs];
        int xstep = 256 / divs;
        int ystep = 256 / divs;
        double grid_width_mult = (width / (pbbox[2] - pbbox[0])) / (width / divs);
        double grid_height_mult = (height / (pbbox[1] - pbbox[3])) / (height / divs);
        int x, y;

        //for 4326
        double top = tilebbox[3];
        double bottom = tilebbox[1];
        double left = tilebbox[0];
        double right = tilebbox[2];

        for (int j = 0; j < points.size(); j++) {

            if (hq != null && hq.contains(j)) {
                //dont render these points
                continue;
            }

            List<OccurrencePoint> ps = points.get(j);

            if (ps == null || ps.isEmpty()) {
                continue;
            }

            if (imgObj == null || imgObj.img == null) {
                imgObj = ImgObj.create(width, height);
            }

            if (vars.colourMode.equals("grid")) {
                //populate grid
                for (int i = 0; i < ps.size(); i++) {
                    OccurrencePoint pt = ps.get(i);
                    float lng = pt.getCoordinates().get(0).floatValue();
                    float lat = pt.getCoordinates().get(1).floatValue();

                    if (is4326) {
                        x = convertLngToPixel4326(lng, left, right, width);
                        y = convertLatToPixel4326(lat, top, bottom, height);
                    } else {
                        x = (int) ((convertLngToPixel(lng) - pbbox[0]) * grid_width_mult);
                        y = (int) ((convertLatToPixel(lat) - pbbox[3]) * grid_height_mult);
                    }

                    if (x >= 0 && x < divs && y >= 0 && y < divs) {
                        gridCounts[x][y] += pt.getCount();
                    }
                }
            } else {
                renderPoints(vars, pbbox, width_mult, height_mult, pointWidth, outlinePoints, outlineColour,
                        pColour, imgObj, j, ps, is4326, tilebbox, height, width);
            }
        }

        //no points
        if (imgObj == null || imgObj.img == null) {
            if (vars.highlight == null) {
                displayBlankImage(response);
                return null;
            }
        } else if (vars.colourMode.equals("grid")) {
            //draw grid
            for (x = 0; x < divs; x++) {
                for (y = 0; y < divs; y++) {
                    int v = gridCounts[x][y];
                    if (v > 0) {
                        if (v > 500) {
                            v = 500;
                        }
                        int colour = (((500 - v) / 2) << 8) | (vars.alpha << 24) | 0x00FF0000;
                        imgObj.g.setColor(new Color(colour));
                        imgObj.g.fillRect(x * xstep, y * ystep, xstep, ystep);
                    }
                }
            }
        } else {
            drawUncertaintyCircles(requestParams, vars, height, width, pbbox, mbbox, width_mult, height_mult,
                    imgObj.g, originalFqs, boundingBoxFqs, is4326, tilebbox);
        }

        //highlight
        if (vars.highlight != null) {
            imgObj = drawHighlight(requestParams, vars, pointType, width, height, pbbox, width_mult, height_mult,
                    imgObj, originalFqs, boundingBoxFqs, is4326, tilebbox);
        }

        return imgObj;
    }

    private void renderPoints(WmsEnv vars, double[] pbbox, double width_mult, double height_mult, int pointWidth,
            boolean outlinePoints, String outlineColour, List<Integer> pColour, ImgObj imgObj, int j,
            List<OccurrencePoint> ps, boolean is4326, double[] tilebbox, int height, int width) {
        int x;
        int y;
        Paint currentFill = new Color(pColour.get(j), true);
        imgObj.g.setPaint(currentFill);
        Color oColour = Color.decode(outlineColour);

        //for 4326
        double top = tilebbox[3];
        double bottom = tilebbox[1];
        double left = tilebbox[0];
        double right = tilebbox[2];

        for (int i = 0; i < ps.size(); i++) {
            OccurrencePoint pt = ps.get(i);
            float lng = pt.getCoordinates().get(0).floatValue();
            float lat = pt.getCoordinates().get(1).floatValue();

            if (is4326) {
                x = convertLngToPixel4326(lng, left, right, width);
                y = convertLatToPixel4326(lat, top, bottom, height);
            } else {
                x = (int) ((convertLngToPixel(lng) - pbbox[0]) * width_mult);
                y = (int) ((convertLatToPixel(lat) - pbbox[3]) * height_mult);
            }

            //System.out.println("Drawing an oval.....");
            imgObj.g.fillOval(x - vars.size, y - vars.size, pointWidth, pointWidth);
            if (outlinePoints) {
                imgObj.g.setPaint(oColour);
                imgObj.g.drawOval(x - vars.size, y - vars.size, pointWidth, pointWidth);
                imgObj.g.setPaint(currentFill);
            }
        }
    }

    //method from 1.3.3.1 Mercator (Spherical) http://www.epsg.org/guides/docs/g7-2.pdf
    //constant from EPSG:900913
    private double[] transformBbox4326To900913(double long1, double lat1, double long2, double lat2) {
        return new double[] { 6378137.0 * long1 * Math.PI / 180.0,
                6378137.0 * Math.log(Math.tan(Math.PI / 4.0 + lat1 * Math.PI / 360.0)),
                6378137.0 * long2 * Math.PI / 180.0,
                6378137.0 * Math.log(Math.tan(Math.PI / 4.0 + lat2 * Math.PI / 360.0)) };
    }

    public void setTaxonDAO(TaxonDAO taxonDAO) {
        this.taxonDAO = taxonDAO;
    }

    public void setSearchDAO(SearchDAO searchDAO) {
        this.searchDAO = searchDAO;
    }

    public void setSearchUtils(SearchUtils searchUtils) {
        this.searchUtils = searchUtils;
    }

    public void setBaseWsUrl(String baseWsUrl) {
        this.baseWsUrl = baseWsUrl;
    }

    public void setOrganizationName(String organizationName) {
        this.organizationName = organizationName;
    }

    public void setOrgCity(String orgCity) {
        this.orgCity = orgCity;
    }

    public void setOrgStateProvince(String orgStateProvince) {
        this.orgStateProvince = orgStateProvince;
    }

    public void setOrgPostcode(String orgPostcode) {
        this.orgPostcode = orgPostcode;
    }

    public void setOrgCountry(String orgCountry) {
        this.orgCountry = orgCountry;
    }

    public void setOrgPhone(String orgPhone) {
        this.orgPhone = orgPhone;
    }

    public void setOrgFax(String orgFax) {
        this.orgFax = orgFax;
    }

    public void setOrgEmail(String orgEmail) {
        this.orgEmail = orgEmail;
    }
}

class WmsEnv {

    private final static Logger logger = Logger.getLogger(WmsEnv.class);
    public int red, green, blue, alpha, size, colour;
    public boolean uncertainty;
    public String colourMode, highlight;

    /**
     * Get WMS ENV values from String, or use defaults.
     *
     * @param env
     */
    public WmsEnv(String env, String styles) {
        try {
            env = URLDecoder.decode(env, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            logger.error(e.getMessage(), e);
        }

        red = green = blue = alpha = 0;
        size = 4;
        uncertainty = false;
        highlight = null;
        colourMode = "-1";
        colour = 0x00000000; //rgba

        if (StringUtils.trimToNull(env) == null && StringUtils.trimToNull(styles) == null) {
            env = "color:cd3844;size:10;opacity:1.0";
        }

        if (StringUtils.trimToNull(env) != null) {

            for (String s : env.split(";")) {
                String[] pair = s.split(":");
                pair[1] = s.substring(s.indexOf(":") + 1);
                if (pair[0].equals("color")) {
                    while (pair[1].length() < 6) {
                        pair[1] = "0" + pair[1];
                    }
                    red = Integer.parseInt(pair[1].substring(0, 2), 16);
                    green = Integer.parseInt(pair[1].substring(2, 4), 16);
                    blue = Integer.parseInt(pair[1].substring(4), 16);
                } else if (pair[0].equals("size")) {
                    size = Integer.parseInt(pair[1]);
                } else if (pair[0].equals("opacity")) {
                    alpha = (int) (255 * Double.parseDouble(pair[1]));
                } else if (pair[0].equals("uncertainty")) {
                    uncertainty = true;
                } else if (pair[0].equals("sel")) {
                    highlight = s.replace("sel:", "").replace("%3B", ";");
                } else if (pair[0].equals("colormode")) {
                    colourMode = pair[1];
                }
            }
        } else if (StringUtils.trimToNull(styles) != null) {
            //named styles
            //blue;opacity=1;size=1
            String firstStyle = styles.split(",")[0];
            String[] styleParts = firstStyle.split(";");

            red = Integer.parseInt(styleParts[0].substring(0, 2), 16);
            green = Integer.parseInt(styleParts[0].substring(2, 4), 16);
            blue = Integer.parseInt(styleParts[0].substring(4), 16);
            alpha = (int) (255 * Double.parseDouble(styleParts[1].substring(8)));
            size = Integer.parseInt(styleParts[2].substring(5));
        }

        colour = (red << 16) | (green << 8) | blue;
        colour = colour | (alpha << 24);
    }
}

class ImgObj {

    Graphics2D g;
    BufferedImage img;

    public static ImgObj create(int width, int height) {
        BufferedImage img = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        Graphics2D g = (Graphics2D) img.getGraphics();
        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
        return new ImgObj(g, img);
    }

    public ImgObj(Graphics2D g, BufferedImage img) {
        this.g = g;
        this.img = img;
    }
}