20

StackOverflow is loaded with questions about how to check if an element is really visible in the viewport, but they all seek for a boolean answer. I'm interested in getting the element's actual areas that are visible.

function getVisibleAreas(e) {
    ...
    return rectangleSet;
}

Putting it more formally - the visible areas of elements is the set of (preferably non-overlapping) rectangles in CSS coordinates for which elementFromPoint(x, y) will return the element if the point (x, y) is contained in (at least) one of the rectangles in the set.

The outcome of calling this function on all DOM elements (including iframes) should be a set of non-overlapping area sets which union is the entire viewport area.

My goal is to create some kind of a viewport "dump" data structure, which can efficiently return a single element for a given point in the viewport, and vice versa - for a given element in the dump, it will return the set of visible areas. (The data structure will be passed to a remote client application, so I will not necessarily have access to the actual document when I need to query the viewport structure).

Implementation requirements:

  • Obviously, the implementation should consider element's hidden state, z-index, header & footer etc.
  • I am looking for an implementation that works in all common used browsers, especially mobile - Android's Chrome and iOS's Safari.
  • Preferably doesn't use external libraries.

    Of course, I could be naïve and call elementFromPoint for every discrete point in the viewport, But performance is crucial since I iterate over all of the elements, and will do it quite often.

    Please direct me as to how I can achieve this goal.

    Disclaimer: I'm pretty noob to web programming concepts, so I might have used wrong technical terms.

    Progress:

    I came up with an implementation. The algorithm is pretty simple:

    1. Iterate over all elements, and add their vertical / horizontal lines to a coordinates map (if the coordinate is within the viewport).
    2. Call `document.elementFromPoint` for each "rectangle" center position. A rectangle is an area between two consecutive vertical and two consecutive horizontal coordinates in the map from step 1.

    This produces a set of areas / rectangles, each pointing to a single element.

    The problems with my implementation are:

    1. It is inefficient for complicated pages (can take up to 2-4 minutes for a really big screen and gmail inbox).
    2. It produces a large amount of rectangles per a single element, which makes it inefficient to stringify and send over a network, and also inconvenient to work with (I would want to end up with a set with as few rectangles as possible per element).

    As much as I can tell, the elementFromPoint call is the one that takes a lot of time and causes my algorithm to be relatively useless...

    Can anyone suggest a better approach?

    Here is my implementation:

    function AreaPortion(l, t, r, b, currentDoc) {
        if (!currentDoc) currentDoc = document;
        this._x = l;
        this._y = t;
        this._r = r;
        this._b = b;
        this._w = r - l;
        this._h = b - t;
    
        center = this.getCenter();
        this._elem = currentDoc.elementFromPoint(center[0], center[1]);
    }
    
    AreaPortion.prototype = {
        getName: function() {
            return "[x:" + this._x + ",y:" + this._y + ",w:" + this._w + ",h:" + this._h + "]";
        },
    
        getCenter: function() {
            return [this._x + (this._w / 2), this._y + (this._h / 2)];
        }
    }
    
    function getViewport() {
        var viewPortWidth;
        var viewPortHeight;
    
        // IE6 in standards compliant mode (i.e. with a valid doctype as the first line in the document)
        if (
                typeof document.documentElement != 'undefined' &&
                typeof document.documentElement.clientWidth != 'undefined' &&
                document.documentElement.clientWidth != 0) {
            viewPortWidth = document.documentElement.clientWidth,
            viewPortHeight = document.documentElement.clientHeight
        }
    
        // the more standards compliant browsers (mozilla/netscape/opera/IE7) use window.innerWidth and window.innerHeight
        else if (typeof window.innerWidth != 'undefined') {
            viewPortWidth = window.innerWidth,
            viewPortHeight = window.innerHeight
        }
    
        // older versions of IE
        else {
            viewPortWidth = document.getElementsByTagName('body')[0].clientWidth,
            viewPortHeight = document.getElementsByTagName('body')[0].clientHeight
        }
    
        return [viewPortWidth, viewPortHeight];
    }
    
    function getLines() {
        var onScreen = [];
        var viewPort = getViewport();
        // TODO: header & footer
        var all = document.getElementsByTagName("*");
    
        var vert = {};
        var horz = {};
    
        vert["0"] = 0;
        vert["" + viewPort[1]] = viewPort[1];
        horz["0"] = 0;
        horz["" + viewPort[0]] = viewPort[0];
        for (i = 0 ; i < all.length ; i++) {
            var e = all[i];
            // TODO: Get all client rectangles
            var rect = e.getBoundingClientRect();
            if (rect.width < 1 && rect.height < 1) continue;
    
            var left = Math.floor(rect.left);
            var top = Math.floor(rect.top);
            var right = Math.floor(rect.right);
            var bottom = Math.floor(rect.bottom);
    
            if (top > 0 && top < viewPort[1]) {
                vert["" + top] = top;
            }
            if (bottom > 0 && bottom < viewPort[1]) {
                vert["" + bottom] = bottom;
            }
            if (right > 0 && right < viewPort[0]) {
                horz["" + right] = right;
            }
            if (left > 0 && left < viewPort[0]) {
                horz["" + left] = left;
            }
        }
    
        hCoords = [];
        vCoords = [];
        //TODO: 
        for (var v in vert) {
            vCoords.push(vert[v]);
        }
    
        for (var h in horz) {
            hCoords.push(horz[h]);
        }
    
        return [hCoords, vCoords];
    }
    
    function getAreaPortions() {
        var portions = {}
        var lines = getLines();
    
        var hCoords = lines[0];
        var vCoords = lines[1];
    
        for (i = 1 ; i < hCoords.length ; i++) {
            for (j = 1 ; j < vCoords.length ; j++) {
                var portion = new AreaPortion(hCoords[i - 1], vCoords[j - 1], hCoords[i], vCoords[j]);
                portions[portion.getName()] = portion;
            }
        }
    
        return portions;
    }
    
  • Community
    • 1
    • 1
    Elist
    • 5,313
    • 3
    • 35
    • 73
    • If you want to know the visible height of an element in the viewport, check may answer to [this question](http://stackoverflow.com/questions/24768795/get-the-visible-height-of-a-div-with-jquery/24768959#24768959). I won't mark this as a duplicate as your requirements may be different. – Rory McCrossan Nov 26 '14 at 10:21
    • @t.niese If I understand your question right - you ask if computing and constructing the data structure should be done on server side or client side - The answer is I don't care, as long as the size of data passed on the wire is similar. The outcome should be the data available stand-alone on the client side for later use. – Elist Nov 26 '14 at 10:31
    • @RoryMcCrossan - voted up your answer and it gives my some idea about `offset` concept, but indeed, it doesn't meet my requrements... – Elist Nov 26 '14 at 10:34
    • Is there a way to peek in the javaScript implementation for `elementFromPoint`? That would be a great starting point for me. – Elist Nov 26 '14 at 13:43
    • perhaps you can be semi-naive and jump 10px in a elementFromPoint() sweep instead of 1px. you then backtrack (or go to 1px rez) only if the element is not the same as the time before. also, getBoundingClientRect() is expensive, and you can exit the loop early by checking before calling it using something like if(!e.scrollHeight || !e.scrollWidth) continue; – dandavis Dec 07 '14 at 00:53

    3 Answers3

    1

    Try

    var res = [];
    $("body *").each(function (i, el) {
        if ((el.getBoundingClientRect().bottom <= window.innerHeight 
            || el.getBoundingClientRect().top <= window.innerHeight)
            && el.getBoundingClientRect().right <= window.innerWidth) {
                res.push([el.tagName.toLowerCase(), el.getBoundingClientRect()]);
        };
    });
    

    jsfiddle http://jsfiddle.net/guest271314/ueum30g5/

    See Element.getBoundingClientRect()

    $.each(new Array(180), function () {
        $("body").append(
        $("<img>"))
    });
    
    $.each(new Array(180), function () {
    $("body").append(
    $("<img>"))
    });
    
    var res = [];
    $("body *").each(function (i, el) {
    if ((el.getBoundingClientRect().bottom <= window.innerHeight || el.getBoundingClientRect().top <= window.innerHeight)
        && el.getBoundingClientRect().right <= window.innerWidth) {
        res.push(
        [el.tagName.toLowerCase(),
        el.getBoundingClientRect()]);
        $(el).css(
            "outline", "0.15em solid red");
        $("body").append(JSON.stringify(res, null, 4));
        console.log(res)
    };
    });
    body {
        width : 1000px;
        height : 1000px;
    }
    img {
        width : 50px;
        height : 50px;
        background : navy;
    }
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.11.1/jquery.min.js"></script>
    guest271314
    • 1
    • 15
    • 104
    • 177
    • @ZachSaucier Elements partially within viewport can either be a) included within `res`, or ; b) excluded from `res` , by adjusting `|| el.getBoundingClientRect().top <= window.innerHeight` (includes elements _partially_ within viewport - vertically; remove to exclude elements whose top portion is partially within viewport) , and `el.getBoundingClientRect().right <= window.innerWidth` (includes elements _partially_ within viewport - horizontally; adjust to exclude partially viewed). Thanks – guest271314 Dec 03 '14 at 04:50
    • 1
      Thanks, but your script doesn't handle overlapping elements, which is realy my problem... – Elist Dec 03 '14 at 10:57
    • @Elist exactly. All the answers always involve "window" where elements can be hidden by others or sometimes with hidden overflow – Nathan B Apr 12 '19 at 08:45
    1

    I don't know if the performance will be sufficient (especially on a mobile device), and the result is not quite a rectangle-set as you requested, but did you consider using a bitmap to store the result?

    Note some elements may have 3d css transform (eg. skew, rotate), some elements may have border radius, and some elements may have invisible background - if you want to include these features as well for your "element from pixel" function then a rectangle set can't help you - but the bitmap can accommodate all of the visual features.

    The solution to generate the bitmap is rather simple (I imagine... not tested):

    1. Create a Canvas the size of the visible screen.
    2. iterate over all the elements recursively, sorted by z-order, ignore hidden
    3. for each element draw a rectangle in the canvas, the color of the of the rectangle is an identifier of the element (eg. could be incremental counter). If you want you can modify the rectangle based on the visual features of the element (skew, rotate, border radius, etc...)
    4. save the canvas as lossless format, eg png not jpg
    5. send the bitmap as the meta data of elements on screen

    To query which element is at point (x,y) you could check the color of the bitmap at pixel (x,y) and the color will tell you what is the element.

    Iftah
    • 9,512
    • 2
    • 33
    • 45
    • Interesting approach. Are there no other aspects of visibility I should consider (other then z-index and css-displayed)? I mean, can't elements be shifted / float / appear outside of thier parent bounds? – Elist Dec 08 '14 at 15:42
    • To be honest, I'm not sure. Elements can certainly be outside their parent bounds, but that is not an issue with above algorithm. I know there will be complications with iframes and IE7, and probably other issues I can't think of this late at night, but I think its doable. – Iftah Dec 08 '14 at 23:17
    • Another possible solution that is not quite rectangle-set you asked for - Sync the entire DOM state to the server, and then use server side browser component (eg. http://phantomjs.org/) to ask what is the element at point (x,y) – Iftah Dec 09 '14 at 08:50
    • Can you be more specific on how to take a snapshot of the DOM state (including client size, scroll, zoom etc)? – Elist Dec 09 '14 at 10:53
    • I think `document.body.innerHTML` should do the trick, but it may be large for a complex website, so perhaps not suitable if you wish to sync state with server very quickly. – Iftah Dec 09 '14 at 11:02
    0

    If you can jettison IE, here's a simple one:

    function getElementVisibleRect(el) {
      return new Promise((resolve, reject) => {
        el.style.overflow = "hidden";
        requestAnimationFrame((timeStamp) => {
          var br = el.getBoundingClientRect();
          el.style.overflow = "";
          resolve(br);
        });
      });
    }
    

    Even then, Promises are easily polyfillable and requestAnimationFrame() works as far back as IE 8. And by 2016, the only thing you should bother to give any poor souls on older IE is a legible experience.

    • can you explain how changing the `style.overflow` and calling `requestAnimationFrame` matters? Can't I just call `getBoundingClientRect`? Also This doesn't seem to help in calculating the overlapped / out of viewport areas. – Elist Apr 17 '16 at 06:28