Fantastic Frontier Roblox Wiki
mNo edit summary
mNo edit summary
Line 132: Line 132:
 
 
 
function createImageMask(img) {
 
function createImageMask(img) {
if (img.src in imageMasks)
+
var key = img.src;
return imageMasks[img.src];
+
if (key in imageMasks)
  +
return imageMasks[key];
 
var buffer = document.createElement("canvas"), w = img.width, h = img.height;
 
var buffer = document.createElement("canvas"), w = img.width, h = img.height;
 
buffer.width = w;
 
buffer.width = w;
Line 144: Line 145:
 
for (var i = 3; i < len; i += 4)
 
for (var i = 3; i < len; i += 4)
 
if (pixels[i]) mask[i] = 1;
 
if (pixels[i]) mask[i] = 1;
imageMasks[img.src] = mask;
+
imageMasks[key] = mask;
 
buffer = null, ctx = null, pixels = null, len = null;
 
buffer = null, ctx = null, pixels = null, len = null;
 
console.log("created image mask:", key);
 
console.log("created image mask:", key);

Revision as of 10:29, 16 January 2019

$(function() {
    // All images will be hosted here.
    var image_prefix = "https://vignette.wikia.nocookie.net/fantastic-frontier-roblox/images/";
    var watermark = "8/89/Wiki-wordmark.png";
    
    var MIN_ZOOM = 0.5;
    var MAX_ZOOM = 6;
    
    var DRAG_MARGIN = 100;
    var MARKER_SIZE_LARGE = 64;
    
    // Convert a string of type X,Y into a coordinate object.
    function getXY(str) {
        var arr = str.split(",");
        if (arr.length > 1)
            return {"x": parseInt(arr[0]), "y": parseInt(arr[1])};
        else
            return {"x": -1, "y": -1};
    }
    
    // Credits to https://codepen.io/techslides/pen/zowLd
    // Adds ctx.getTransform() - returns an SVGMatrix
	// Adds ctx.transformedPoint(x,y) - returns an SVGPoint
	function trackTransforms(ctx) {
        var svg = document.createElementNS("http://www.w3.org/2000/svg",'svg');
        var xform = svg.createSVGMatrix();
        ctx.getTransform = function(){ return xform; };
        
        var savedTransforms = [];
        var save = ctx.save;
        ctx.save = function() {
            savedTransforms.push(xform.translate(0, 0));
            return save.call(ctx);
        };
        
        var restore = ctx.restore;
        ctx.restore = function() {
            xform = savedTransforms.pop();
            return restore.call(ctx);
        };
        
        var scale = ctx.scale;
        ctx.scale = function(sx,sy) {
            xform = xform.scaleNonUniform(sx,sy);
            return scale.call(ctx,sx,sy);
        };
        
        var rotate = ctx.rotate;
        ctx.rotate = function(radians) {
            xform = xform.rotate(radians*180/Math.PI);
            return rotate.call(ctx,radians);
        };
        
        var translate = ctx.translate;
        ctx.translate = function(dx,dy) {
            xform = xform.translate(dx,dy);
            return translate.call(ctx,dx,dy);
        };
        
        var transform = ctx.transform;
        ctx.transform = function(a,b,c,d,e,f) {
            var m2 = svg.createSVGMatrix();
            m2.a=a; m2.b=b; m2.c=c; m2.d=d; m2.e=e; m2.f=f;
            xform = xform.multiply(m2);
            return transform.call(ctx,a,b,c,d,e,f);
        };
        
        var setTransform = ctx.setTransform;
        ctx.setTransform = function(a,b,c,d,e,f) {
            xform.a = a;
            xform.b = b;
            xform.c = c;
            xform.d = d;
            xform.e = e;
            xform.f = f;
            return setTransform.call(ctx,a,b,c,d,e,f);
        };
        
        var pt  = svg.createSVGPoint();
        ctx.transformedPoint = function(x,y) {
            pt.x = x;
            pt.y = y;
            return pt.matrixTransform(xform.inverse());
        }
	}
	
	// Copied from our checklist script, so we can integrate progress to quest cards.
	// Variables to determine which save/load methods are available. We want to save checklists persistently for the user when they return.
    var localstorageEnabled, cookiesEnabled = false;
    
    // Checks if localStorage is available.
    var lsTestKey = 'test' + Math.floor(1000 + Math.random() * 99000);
    try {
        localStorage.setItem(lsTestKey, lsTestKey);
        localStorage.removeItem(lsTestKey);
        localstorageEnabled = true;
    } catch(e) {
        localstorageEnabled = false;
    }
    // If localStorage is unavailable, we'll check if cookies are enabled.
    if (!localstorageEnabled) {
        cookiesEnabled = navigator.cookieEnabled;
    }
    
    // Code to get / set cookies from their cookie name. Used if localStorage is unavailable.
    function getCookieValueByRegEx(cookieName) {
        var cookieArray = document.cookie.match('(^|;)\\s*' + cookieName + '\\s*=\\s*([^;]+)');
        return cookieArray ? cookieArray.pop() : '';
    }
    function setCookie(cname, cvalue, expiresInDays) {
        var d = new Date();
        d.setTime(d.getTime() + (expiresInDays * 24 * 60 * 60 * 1000));
        var expires = "expires=" + d.toUTCString();
        document.cookie = cname + "=" + cvalue + ";" + expires + ";path=/";
    }
    
    // Loads the checklist data for a specific checklist as an object.
    function loadChecklistData(tableId) {
        if (localstorageEnabled) {
            var item = localStorage.getItem("checklist_table_" + tableId);
            return item !== null ? JSON.parse(item) : {};
        } else if (cookiesEnabled) {
            var cookieValue = getCookieValueByRegEx("checklist_table_" + tableId);
            return cookieValue.length > 0 ? JSON.parse(cookieValue) : {};
        } else {
            return {};
        }
    }
	
	var outlineBuffer = document.createElement("canvas");
	var imageMasks = {};
	
    function createImageMask(img) {
        var key = img.src;
        if (key in imageMasks)
            return imageMasks[key];
        var buffer = document.createElement("canvas"), w = img.width, h = img.height;
        buffer.width = w;
        buffer.height = h;
        var ctx = buffer.getContext("2d");
        ctx.drawImage(img, 0, 0);
        var pixels = ctx.getImageData(0, 0, w, h).data;
        var mask = {};
        var len = pixels.length;
        for (var i = 3; i < len; i += 4)
            if (pixels[i]) mask[i] = 1;
        imageMasks[key] = mask;
        buffer = null, ctx = null, pixels = null, len = null;
        console.log("created image mask:", key);
        return mask;
	}
	
	// Hover outline.
	// Credits to https://stackoverflow.com/a/28416298
	function drawOutline(canvas, ctx, img, x, y, size) {
        var w = img.width;
        var h = img.height;
        outlineBuffer.width = w + 10;
        outlineBuffer.height = h + 10;
        var bctx = outlineBuffer.getContext("2d");
        bctx.clearRect(0, 0, w + 10, h + 10);
        
        var dArr = [-1,-1, 0,-1, 1,-1, -1,0, 1,0, -1,1, 0,1, 1,1], // offset array
            s = 1,  // thickness scale
            i = 0;  // iterator
        
        // draw images at offsets from the array scaled by s
        for(; i < dArr.length; i += 2)
            bctx.drawImage(
                img, 5 + dArr[i] * s, 5 + dArr[i+1] * s, w, h);
        
        // fill with color
        bctx.globalCompositeOperation = "source-in";
        bctx.fillStyle = "white";
        bctx.fillRect(0, 0, canvas.width, canvas.height);
        
        // draw original image in normal mode
        bctx.globalCompositeOperation = "source-over";
        bctx.drawImage(img, 5, 5, w, h);
        
        var scaleUp = (w + 10) / w;
        var margin = (scaleUp - 1) * 0.5;
        ctx.drawImage(outlineBuffer, x - size * margin, y - size * margin, size * scaleUp, size * scaleUp);
	}
	
	// Hover detection
    function inArea(ctx, px, py, bx, by, w, h) {
        var p = ctx.transformedPoint(px, py);
        return p.x >= bx && p.y >= by && p.x <= bx + w && p.y <= by + h;
    }
    
    function inImage(ctx, key, img, px, py, bx, by, w, h) {
        if (key == "c/c5/Minimap_corrupted_wizard.png")
            console.log("inImage:", ctx, key, img, px, py, bx, by, w, h);
        if (((image_prefix + key) in imageMasks) && inArea(ctx, px, py, bx, by, w, h)) {
            var p = ctx.transformedPoint(px, py);
            var x = ((p.x - bx) / w) * img.width, y = ((p.y - by) / h) * img.height;
            if (key == "c/c5/Minimap_corrupted_wizard.png")
                console.log("inImage is in area:", key, x, y);
            return imageMasks[image_prefix + key][4 * (x + y * w) + 3] === 1;
        }
        return false;
    }
    
    function capitalize(str) 
    {
        return str.charAt(0).toUpperCase() + str.slice(1);
    }
    
    function formatType(type) {
        if (type == "npc") return "NPC";
        var words = type.split("_");
        for (var i = 0; i < words.length; i++)
            words[i] = capitalize(words[i]);
        return words.join(" ");
    }
    
    // Credit to https://stackoverflow.com/a/2901298
    function numberWithCommas(x) {
        return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
    }
    
    function appendTextLine(elem, text) {
        elem.append("<br/>");
        elem.append(document.createTextNode(text));
    }
    
    // Generate a tooltip card for the current entity.
    function showTooltip(map, tooltip, entity_data) {
        tooltip.find(".icon").css({"background-image": "url('" + image_prefix + entity_data.image + "')"});
        tooltip.find(".title").text(entity_data.article_text);
        var types = entity_data.types.slice();
        for (var i = 0; i < types.length; i++)
            types[i] = formatType(types[i]);
        var info = tooltip.find(".info");
        info.text(types.join(", "));
        // Entity has HP, show HP
        if ("hp" in entity_data)
            appendTextLine(info, "HP: " + entity_data.hp);
        // Entity is a vendor, show list of vendor items
        if ("vendor_list" in entity_data) {
            var vl = $("<div class=\"vendor-list\"><span>Vendor items:</span><br><ul></ul></div>");
            for (var i = 0; i < entity_data.vendor_list.length; i++)
                vl.find("ul").append($("<li></li>").text(entity_data.vendor_list[i]));
            info.append(vl);
        }
        // Entity is a quest giver, show quest info-card
        if ("quest_giver" in entity_data) {
            var quest_data = entity_data.quest_giver;
            var qi = $("<div class=\"quest-info\"><img/><span class=\"quest-title\"></span><span class=\"quest-desc\"></span><span class=\"quest-progress\"></span></div>");
            qi.find("img").attr("src", image_prefix + quest_data.icon);
            appendTextLine(qi.find(".quest-title").text("Quest:"), quest_data.name);
            qi.find(".quest-desc").text(quest_data.description);
            if ("checklist_id" in quest_data) {
                var quest_progress = 0;
                var quest_progress_data = loadChecklistData(quest_data.checklist_id);
                for (var i = 0; i < quest_data.checklist_items.length; i++)
                    if (quest_progress_data[quest_data.checklist_items[i]] === true)
                        quest_progress++;
                qi.find(".quest-progress").text(
                    Math.min(quest_progress, quest_data.checklist_items.length) +
                    " / " + quest_data.checklist_items.length);
            }
            info.append(qi);
        }
        // Entity is teleport, show destinations
        if ("teleport" in entity_data) {
            var tp_data = entity_data.teleport;
            var ti = $("<div class=\"teleport-info\"><span class=\"tp-title\">Teleport destinations</span><ul></ul></div>");
            for (var i = 0; i < tp_data.length; i++)
                ti.find("ul").append($("<li></li>").text(tp_data[i].text));
            info.append(ti);
        }
        // Entity has a screenshot, show screenshot info-card
        if ("screenshot" in entity_data) {
            var scc = $("<div class=\"scr-info\"><img/><span class=\"price\"></span></div>");
            scc.find("img").attr("src", image_prefix + entity_data.screenshot.image);
            if ("price" in entity_data.screenshot)
                scc.find(".price").text(numberWithCommas(entity_data.screenshot.price) + "g");
            info.append(scc);
        }
        tooltip.show();
    }
    
    function hideTooltip(map, tooltip) {
        tooltip.hide();
    }
    
    function createToggleElem(toggles_enabled, redraw, info) {
        var elem = $("<label><div class=\"color-indicator\"></div><input type=\"checkbox\"><span></span></label>");
        elem.find(".color-indicator").css({"background-color": info.color});
        elem.find("span").text(formatType(info.id));
        elem.find("input").attr("value", info.id);
        
        elem.find("input").change(function() {
            if (this.checked) {
                var index = toggles_enabled.indexOf(info.id);
                if (index < 0)
                    toggles_enabled.push(info.id);
            } else {
                var index = toggles_enabled.indexOf(info.id);
                if (index > -1)
                    toggles_enabled.splice(index, 1);
            }
            redraw();
        });
        
        if (toggles_enabled.includes(info.id))
            elem.find("input").prop('checked', true);
        
        return elem;
    }
    
    function isMarkerEnabled(toggles_enabled, marker_data) {
        for (var i = 0; i < marker_data.types.length; i++)
            if (toggles_enabled.indexOf(marker_data.types[i]) > -1)
                return true;
        return false;
    }
    
    // Creates a list over the minimap, from which the user can pick a choice.
    function createPromptList(map, title, options, callback) {
        var ol = $("<div class=\"minimap-overlay\"><ul><li class=\"option-title\"></li></ul></div>");
        ol.find(".option-title").text(title);
        
        options = options.slice(); // Copy array.
        options.push({"text": "Cancel"});
        
        var onClick = function() {
            var opt = $(this);
            var data = opt.data("option-data");
            ol.remove();
            if (data.text !== "Cancel")
                callback(data);
        };
        
        for (var i = 0; i < options.length; i++) {
            var opt = $("<li class=\"option\"></li>");
            opt.text(options[i].text);
            opt.data("option-data", options[i]);
            opt.click(onClick);
            ol.find("ul").append(opt);
        }
        
        var h = map.height();
        var w = map.width();
        ol.css({
            "top": (-h) + "px", "margin-bottom": (-h) + "px", "width": w + "px", "height": h + "px"
        });
        map.after(ol);
    }
    
    // Find our base information for all maps. The data will be served as JSON.
    // Unfortunately, I couldn't find an endpoint that gives the raw page data.
    $.get("/wiki/Template:MapData", function(data_string) {
        var dom = $(data_string);
		var data = JSON.parse(dom.find(".mw-content-text>p").text());
		
		var loadMinimap = function(ph_map, loadData) {
		    // Gets the user-submitted data for this minimap.
            var height = loadData.height;
            var width = loadData.width;
            var world = loadData.world;
            var view_pos = loadData.view_pos;
            var toggles_enabled = loadData.toggles_enabled;
            var zoom_level = loadData.zoom_level;
            
            var hovered_marker = null;
            var hovering_region = null;
            
            // Checks if the world is valid.
            if (world in data.worlds)
            {
                var world_data = data.worlds[world];
                
                var map_wrapper = $("<a class='minimap-wrapper'><canvas class='minimap loaded'>Your browser does not support HTML5 Canvas. Minimaps can't show.</canvas></a>");
                var map = map_wrapper.find(".minimap");
                map_wrapper.css({"width": width + "px"});
                map.css({
                    "width": width + "px",
                    "height": height + "px",
                    "background-color": world_data.background_color,
                });
                
                var tooltip = $("<div class='minimap-tooltip' style='display:hidden'><div class='icon'></div><div class='content'><div class='title'></div><div class='info'></div></div></div>");
                
                // We're using a canvas to draw our minimap.
                var canvas = map[0];
                canvas.width = width;
                canvas.height = height;
                var ctx = canvas.getContext("2d");
                
                trackTransforms(ctx);
                
                // We're storing all images in an associative lookup, avoiding duplicates.
                var images = {};
                
                images[watermark] = new Image();
                
                // Scans for world images.
                for (var key in data.worlds) {
                    if (data.worlds[key].background_image !== "")
                        images[data.worlds[key].background_image] = new Image();
                    for (var i = 0; i < data.worlds[key].detail_tiles.length; i++)
                        images[data.worlds[key].detail_tiles[i].image] = new Image();
                }

                // Scans for marker images.
                for (var key in data.markers)
                    if (data.markers[key].image !== "")
                        images[data.markers[key].image] = new Image();
                
                // Init hover state for markers in the current world.
                for (var i = 0; i < world_data.markers.length; i++)
                    world_data.markers[i].is_hovered = false;
                
                var bg_img = images[world_data.background_image];
                var wm_img = images[watermark];
                
                // Dragging and zooming functionality.
                var lastX = canvas.width / 2, lastY = canvas.height / 2;
                var currentScale = 1;
                
                // Re-evaluates hovering on items and such.
                var recalcHover = function(performMove) {
                    var hover_change = false,
                        hovering_any = false,
                        hovered_region = hovering_region !== null;
                    hovered_marker = null;
                    hovering_region = null;
                    if (toggles_enabled.indexOf("regions") > -1) {
                        for (var i = 0; i < world_data.regions.length; i++) {
                            var region_data = world_data.regions[i];
                            if (region_data.article !== "") {
                                var line_height = region_data.text_scale * 12;
                                ctx.font = "bold " + line_height + "px Rubik,\"Helvetica Neue\",Helvetica,Arial,sans-serif";
                                for (var j = 0; j < region_data.title.length; j++) {
                                    var text = region_data.title[j];
                                    var twidth = ctx.measureText(text).width;
                                    var px = region_data.text_position.x - twidth * 0.5,
                                        py = region_data.text_position.y + j * line_height;
                                    
                                    if (inArea(ctx, lastX, lastY, px, py - line_height * 0.8, twidth, line_height)) {
                                        if (hovering_region != region_data)
                                            hover_change = true;
                                        hovering_region = region_data;
                                        break;
                                    }
                                }
                            }
                        }
                    }
                    if (hovering_region === null) {
                        if (hovered_region)
                            hover_change = true;
                        for (var i = 0; i < world_data.markers.length; i++)
                        {
                            var entity_data = world_data.markers[i];
                            var marker_data = data.markers[entity_data.id];
                            var pos_x = entity_data.location.x - marker_data.icon_size / 2;
                            var pos_y = entity_data.location.y - marker_data.icon_size / 2;
                            var is_hovered = isMarkerEnabled(toggles_enabled, marker_data) && inImage(
                                ctx, marker_data.image, images[marker_data.image], lastX, lastY, pos_x, pos_y, marker_data.icon_size, marker_data.icon_size
                            );
                            if (is_hovered !== entity_data.is_hovered)
                                hover_change = true, hovering_any = hovering_any || is_hovered;
                            entity_data.is_hovered = is_hovered;
                            if (is_hovered)
                                hovered_marker = entity_data;
                        }
                    }
                    
                    if (performMove && dragStart){
                        var pt = ctx.transformedPoint(lastX,lastY);
                        ctx.translate(pt.x-dragStart.x, pt.y-dragStart.y);
                        
                        constrainDrag();
                        redraw();
                    } else if (hover_change)
                        redraw();
                    
                    map_wrapper.removeAttr("href").removeAttr("target");
                    var offset = map.offset();
                    /*if (hover_change && !hovering_any) {
                        hideTooltip(map, tooltip);
                        map.removeClass("interact");
                    } else if (hovering_any) {
                        var offset = map.offset();
                        showTooltip(
                            map,
                            tooltip,
                            data.markers[hovered_marker.id]
                        );
                        if ("article" in data.markers[hovered_marker.id]) {
                            map.addClass("interact");
                            map_wrapper.attr("href", "https://fantastic-frontier-roblox.wikia.com/wiki/" + data.markers[hovered_marker.id].article).attr("target", "");
                        } else if ("teleport" in data.markers[hovered_marker.id]) {
                            map.addClass("interact");
                        } else
                            map.removeClass("interact");
                    }*/
                    if (hovering_region !== null) {
                        map.addClass("interact");
                        map_wrapper.attr("href", "https://fantastic-frontier-roblox.wikia.com/wiki/" + hovering_region.article).attr("target", "__blank");
                    } else if (hovered_marker !== null) {
                        var marker_data = data.markers[hovered_marker.id];
                        showTooltip(
                            map,
                            tooltip,
                            marker_data
                        );
                        if ("article" in marker_data) {
                            map.addClass("interact");
                            map_wrapper.attr("href", "https://fantastic-frontier-roblox.wikia.com/wiki/" + marker_data.article).attr("target", "");
                        } else if ("teleport" in marker_data) {
                            map.addClass("interact");
                        } else
                            map.removeClass("interact");
                    } else {
                        hideTooltip(map, tooltip);
                        map.removeClass("interact");
                    }
                    
                    tooltip.css({
                        "left": (offset.left + lastX - 40) + "px",
                        "top": (offset.top + lastY + 30) + "px"
                    });
                };
                
                // Our drawing function; again, based off of that codepen.
                var redraw = function() {
                    // Clear the entire canvas
                    var p1 = ctx.transformedPoint(0, 0);
                    var p2 = ctx.transformedPoint(canvas.width,canvas.height);
                    ctx.clearRect(p1.x,p1.y,p2.x-p1.x,p2.y-p1.y);
                    
                    ctx.save();
                    ctx.setTransform(1, 0, 0, 1, 0, 0);
                    ctx.clearRect(0, 0, canvas.width,canvas.height);
                    ctx.restore();
                    
                    // Draw the background
                    ctx.drawImage(images[world_data.background_image], 0, 0);
                    // Add detail tiles (high-quality smaller images of the background)
                    for (var i = 0; i < world_data.detail_tiles.length; i++) {
                        var tile_data = world_data.detail_tiles[i];
                        if (currentScale >= tile_data.zoom_scale_min_limit) {
                            var img = images[tile_data.image];
                            var t_width = img.width * tile_data.scale,
                                t_height = img.height * tile_data.scale;
                            if ("rotate" in tile_data)
                                ctx.rotate(tile_data.rotate);
                            ctx.drawImage(
                                img,
                                tile_data.center.x - t_width / 2, tile_data.center.y - t_height / 2,
                                t_width, t_height
                            );
                            if ("rotate" in tile_data)
                                ctx.rotate(-tile_data.rotate);
                        }
                    }
                    
                    if (toggles_enabled.indexOf("regions") > -1) {
                        ctx.lineWidth = 3;
                        ctx.setLineDash([15, 5]);
                        for (var i = 0; i < world_data.regions.length; i++) {
                            if (world_data.regions[i].area !== "") {
                                ctx.translate(
                                    world_data.regions[i].area_offset.x,
                                    world_data.regions[i].area_offset.y
                                );
                                var p = new Path2D(world_data.regions[i].area);
                                ctx.strokeStyle = world_data.regions[i].color;
                                ctx.stroke(p);
                                ctx.translate(
                                    -world_data.regions[i].area_offset.x,
                                    -world_data.regions[i].area_offset.y
                                );
                            }
                        }
                        ctx.setLineDash([]);
                    }
                    
                    for (var i = 0; i < world_data.markers.length; i++) {
                        var entity_data = world_data.markers[i];
                        var marker_data = data.markers[entity_data.id];
                        var pos_x = entity_data.location.x - marker_data.icon_size / 2;
                        var pos_y = entity_data.location.y - marker_data.icon_size / 2;
                        
                        if (isMarkerEnabled(toggles_enabled, marker_data)) {
                            if (hovering_region === null && hovered_marker === entity_data)
                                drawOutline(
                                    canvas, ctx,
                                    images[marker_data.image],
                                    pos_x, pos_y,
                                    marker_data.icon_size
                                );
                            else
                                ctx.drawImage(
                                    images[marker_data.image],
                                    pos_x, pos_y,
                                    marker_data.icon_size, marker_data.icon_size
                                );
                        }
                    }
                    
                    if (toggles_enabled.indexOf("regions") > -1) {
                        for (var i = 0; i < world_data.regions.length; i++) {
                            var region_data = world_data.regions[i];
                            var line_height = region_data.text_scale * 12;
                            ctx.font = "bold " + line_height + "px Rubik,\"Helvetica Neue\",Helvetica,Arial,sans-serif";
                            if (hovering_region == region_data) {
                                ctx.lineWidth = 5;
                                ctx.strokeStyle = "rgba(10, 10, 10, 0.2)";
                                for (var j = 0; j < region_data.title.length; j++) {
                                    var text = region_data.title[j];
                                    var twidth = ctx.measureText(text).width;
                                    ctx.strokeText(
                                        text,
                                        region_data.text_position.x - twidth * 0.5,
                                        region_data.text_position.y + j * line_height + 2
                                    );
                                }
                                ctx.strokeStyle = "white";
                                for (var j = 0; j < region_data.title.length; j++) {
                                    var text = region_data.title[j];
                                    var twidth = ctx.measureText(text).width;
                                    ctx.strokeText(
                                        text,
                                        region_data.text_position.x - twidth * 0.5,
                                        region_data.text_position.y + j * line_height
                                    );
                                }
                            }
                            
                            ctx.lineWidth = 3;
                            ctx.fillStyle = region_data.text_color;
                            ctx.strokeStyle = region_data.text_outline_color;
                            for (var j = 0; j < region_data.title.length; j++) {
                                var text = region_data.title[j];
                                var twidth = ctx.measureText(text).width;
                                ctx.strokeText(
                                    text,
                                    region_data.text_position.x - twidth * 0.5,
                                    region_data.text_position.y + j * line_height
                                );
                                ctx.fillText(
                                    text,
                                    region_data.text_position.x - twidth * 0.5,
                                    region_data.text_position.y + j * line_height
                                );
                            }
                        }
                    }
                    
                    ctx.save();
                    ctx.setTransform(1, 0, 0, 1, 0, 0);
                    ctx.drawImage(
                        wm_img,
                        5, height - 5 - wm_img.height * 0.5,
                        wm_img.width * 0.5, wm_img.height * 0.5
                    );
                    ctx.restore();
                };
                
                var dragStart,dragged;
                
                var toggles_list = $("<div class='minimap-toggles'></div>");
                
                // Teleport callback. This reloads the entire minimap with a new one!
                var performTeleport = function(tp_data) {
                    loadMinimap(map_wrapper, {
                        width: width,
                        height: height,
                        world: tp_data.world,
                        view_pos: tp_data.view_pos,
                        zoom_level: tp_data.zoom_level,
                        toggles_enabled: toggles_enabled
                    });
                    tooltip.remove();
                    toggles_list.remove();
                }
                
                // Clear dragging if the mouse was released while outside the canvas.
                map.on("mousedown mouseover", function (e) {
                    if (!(e.buttons == 1 || e.buttons == 3))
                        dragStart = null;
                }).on("mouseleave", function() {
                    hideTooltip(map, tooltip);
                    map.removeClass("interact");
                });
                var is_redirecting = false;
                map.on("mousedown", function(e) {
                    if (e.buttons == 1) {
                        if (hovering_region !== null) {
                            if ("article" in hovering_region) {
                                is_redirecting = true;
                                window.open("https://fantastic-frontier-roblox.wikia.com/wiki/" + hovering_region.article, "_blank");
                                setTimeout(function() {
                                    is_redirecting = false;
                                }, 200); // Block zooming for 0.2s
                            }
                        } else if (hovered_marker !== null) {
                            var marker_data = data.markers[hovered_marker.id];
                            if ("article" in marker_data) {
                                is_redirecting = true;
                                window.location.href = "https://fantastic-frontier-roblox.wikia.com/wiki/" + marker_data.article;
                            } else if ("teleport" in marker_data) {
                                if (marker_data.teleport.length > 1) {
                                    createPromptList(
                                        map,
                                        "Select destination",
                                        marker_data.teleport,
                                        performTeleport
                                    );
                                } else
                                    performTeleport(marker_data.teleport[0]);
                            }
                        }
                    }
                });
                
                var constrainDrag = function() {
                    var tl = ctx.transformedPoint(0, 0); // Top left corner of map projected back to pixel scale.
                    
                    if (tl.x < -DRAG_MARGIN || tl.y < -DRAG_MARGIN)
                        ctx.translate(
                            Math.min(0, tl.x + DRAG_MARGIN),
                            Math.min(0, tl.y + DRAG_MARGIN)
                        );
                    if ((-tl.x + bg_img.width) * currentScale < canvas.width - DRAG_MARGIN * currentScale || (-tl.y + bg_img.height) * currentScale < canvas.height - DRAG_MARGIN * currentScale)
                        ctx.translate(
                            -Math.min(0, (-tl.x + bg_img.width) * currentScale - canvas.width + DRAG_MARGIN * currentScale) / currentScale,
                            -Math.min(0, (-tl.y + bg_img.height) * currentScale - canvas.height + DRAG_MARGIN * currentScale) / currentScale
                        );
                };
            
                canvas.addEventListener('mousedown',function(evt){
                    document.body.style.mozUserSelect = document.body.style.webkitUserSelect = document.body.style.userSelect = 'none';
                    lastX = evt.offsetX || (evt.pageX - canvas.offsetLeft);
                    lastY = evt.offsetY || (evt.pageY - canvas.offsetTop);
                    dragStart = ctx.transformedPoint(lastX,lastY);
                    dragged = false;
                }, false);
            
                canvas.addEventListener('mousemove',function(evt){
                    lastX = evt.offsetX || (evt.pageX - canvas.offsetLeft);
                    lastY = evt.offsetY || (evt.pageY - canvas.offsetTop);
                    dragged = true;
                    
                    recalcHover(true);
                }, false);
                
                canvas.addEventListener('mouseup',function(evt){
                    dragStart = null;
                    if (!dragged && !is_redirecting) zoom(evt.shiftKey ? -1 : 1 );
                }, false);
                
                var scaleFactor = 1.1;
                
                var zoom = function(clicks, isInit){
                    var pt = ctx.transformedPoint(lastX,lastY);
                    var factor = Math.pow(scaleFactor,clicks);
                    if (currentScale * factor >= MIN_ZOOM && currentScale * factor <= MAX_ZOOM) {
                        currentScale = currentScale * factor;
                        ctx.translate(pt.x,pt.y);
                        ctx.scale(factor,factor);
                        ctx.translate(-pt.x,-pt.y);
                        if (!isInit) constrainDrag();
                        redraw();
                    }
                }
            
                var handleScroll = function(evt){
                    var delta = evt.wheelDelta ? evt.wheelDelta/40 : evt.detail ? -evt.detail : 0;
                    if (delta) zoom(delta);
                    return evt.preventDefault() && false;
                };
                
                canvas.addEventListener('DOMMouseScroll',handleScroll,false);
                canvas.addEventListener('mousewheel',handleScroll,false);
                
                // Init from user template settings.
                currentPosX = -view_pos.x + width / 2;
                currentPosY = -view_pos.y + height / 2;
                ctx.translate(currentPosX, currentPosY);
                
                var imgLoaded = function(ev) {
                    createImageMask(ev.target);
                    redraw();
                };
                
                for (var key in images) {
                    var k = key;
                    var img = images[k];
                    img.crossOrigin = "Anonymous";
                    img.onload = imgLoaded;
                    img.src = image_prefix + key;
                }
                
                // Show our minimap.
                ph_map.replaceWith(map_wrapper);
                $("body").append(tooltip);
                zoom(zoom_level, true);
                hideTooltip(map, tooltip);
                redraw();
                
                toggles_list.css({"top": (-height) + "px", "left": (width + 5) + "px"});
                for (var i = 0; i < data.toggles.length; i++)
                    toggles_list.append(createToggleElem(toggles_enabled, redraw, data.toggles[i]));
                map_wrapper.after(toggles_list);
                toggles_list.css({"margin-bottom": (-toggles_list.height()) + "px"});
            }
		}
        
        // Find all minimap elements.
        $(".minimap").each(function() {
            var ph_map = $(this);
            
            // Gets the user-submitted data for this minimap.
            var height = ph_map.data("height");
            var width = ph_map.data("width");
            var world = ph_map.data("world");
            var pos = ph_map.data("view-pos");
            var toggles_enabled = ph_map.data("toggles-enabled").split(",");
            var zoom_level = parseInt(ph_map.data("zoom-level"));
            
            var failMap = function(reason) {
                ph_map.attr("data-fail-reason", reason);
                ph_map.addClass("fail");
            }
            
            // Check arguments, and fail if invalid.
            if (isNaN(parseInt(height)))
                return failMap("Invalid canvas height.");
            if (isNaN(parseInt(width)))
                return failMap("Invalid canvas width.");
            if (isNaN(zoom_level))
                return failMap("Invalid zoom level.");
            
            // Checks if the world is valid.
            if (world in data.worlds)
            {
                var view_pos = getXY(pos);
                if (view_pos.x < 0 || view_pos.y < 0 || isNaN(view_pos.x) || isNaN(view_pos.y))
                    return failMap("Invalid view position.");
                
                loadMinimap(ph_map, {
                    width: width,
                    height: height,
                    world: world,
                    view_pos: view_pos,
                    toggles_enabled: toggles_enabled,
                    zoom_level: zoom_level
                });
            }
            else
            {
                ph_map.data("fail-reason", "Invalid world.");
                ph_map.addClass("fail");
            }
            
        });
    });
});