Finding and grouping all objects with same value for userData key

I have a document with several hundred objects, all of which have a value set for userData[‘ptwShape’]. Every object with a given ptwShape value has a paired object with the same value. I need to find each pair and group them together, then change some properties on the group.

This code works, but does what I want just one pair at a time.

(() => {
    let namedObjects = canvases[0].graphics.filter( x => (x.userData['ptwShape'] !== undefined) && !(x instanceof Group) ),
        pp1 = namedObjects.pop(),
        pp2 = namedObjects.find( x => x.userData['ptwShape'] === pp1.userData['ptwShape'] )
    namedObjects = namedObjects.filter( x => x.userData['ptwShape'] !== pp1.userData['ptwShape'] )
    let grp = new Group([pp1,pp2])
    grp.name = pp1.name
    grp.setUserData( 'ptwGroup', 'system' )
    grp.locked = true
    console.log(pp1.name)
})();

When I tried to make it loop so that I don’t have to sit there and rerun the same function in the console a couple hundred times, it didn’t work and OmniGraffle went non-responsive and had to be force quit.

Here’s that code:

(() => {
    let namedObjects = canvases[0].graphics.filter( x => (x.userData['ptwShape'] !== undefined) && !(x instanceof Group) )
    while ( namedObjects.length ) {
        let pp1 = namedObjects.pop(),
            pp2 = namedObjects.find( x => x.userData['ptwShape'] === pp1.userData['ptwShape'] )
        namedObjects = namedObjects.filter( x => x.userData['ptwShape'] !== pp1.userData['ptwShape'] )
        let grp = new Group([pp1,pp2])
        grp.name = pp1.name
        grp.setUserData( 'ptwGroup', 'system' )
        grp.locked = true
        console.log(pp1.name)
    }
})();

I can’t quite figure out why the looping version doesn’t work. Any suggestions?

Does the value of namedObjects.length ever reach a false Boolean value ?

The first thing I would try is to:

  • move namedObjects.length out of the while condition
  • put a Boolean variable in there instead
  • set the value of the boolean (from namedObjects.length) at the end of the loop
  • use console.log to watch what is actually happening to namedObjects.length and your derived Boolean value

Two things:

  1. I did have an extra item in my namedObjects array, which made the length odd and meant the condition would never have evaluated to false. Figured that out right after posting, so I fixed that and tried running again.

  2. It still made OG non-responsive but instead of force quitting, I lay down for a nap since I’ve been feeling under the weather lately. When I woke up a couple hours later, my code had finished correctly! And all the console.log() statements got spit out after the loop had completed, which I thought was weird.

So I guess OG is just slow with a lot of objects. I did turn out to have a substantial number more than the “several hundred” I mentioned, on the order of 1200 or so. Maybe I’m just too impatient?

.filter has to apply a test function one by one to a large number of graphics on the canvas, so repeating the filter in every loop has an exponential character, which is going to cause a bit of a time explosion with hundreds of graphics.

I think there are probably various ways that you could refactor to avoid that.

( Operations like grouping over 600+ pairs may also take a little time - it took 20 seconds here to create 4000 randomly placed rectangles in pairs which share a value for userData[‘ptwShape’] )

This approach (source below) still contains some redundancy (could be made more efficient by fusing sortOn with groupBy, to reduce the number of times that any given shape is queried for its ptwShape value).

Nevertheless, it is already a bit more linear, and takes 20 seconds here with 4000 shapes (creating 2000 pair groups);

It calls an omniJS function from JXA, so you can run it from Script Editor etc.

(() => {
    'use strict';

    // OG JS Context (omniJS) -----------------------------------------------

    const ogJSContext = () => {

        // Tuple (,) :: a -> b -> (a, b)
        const Tuple = (a, b) => ({
            type: 'Tuple',
            '0': a,
            '1': b
        });

        // compare :: a -> a -> Ordering
        const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);

        // flatten :: NestedList a -> [a]
        const flatten = t =>
            Array.isArray(t) ? (
                [].concat.apply([], t.map(flatten))
            ) : t;

        // Typical usage: groupBy(on(eq, f), xs)
        // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
        const groupBy = (f, xs) => {
            const dct = xs.slice(1)
                .reduce((a, x) => {
                    const h = a.active.length > 0 ? a.active[0] : undefined;
                    return h !== undefined && f(h, x) ? {
                        active: a.active.concat([x]),
                        sofar: a.sofar
                    } : {
                        active: [x],
                        sofar: a.sofar.concat([a.active])
                    };
                }, {
                    active: xs.length > 0 ? [xs[0]] : [],
                    sofar: []
                });
            return dct.sofar.concat(dct.active.length > 0 ? [dct.active] : []);
        };

        // map :: (a -> b) -> [a] -> [b]
        const map = (f, xs) => xs.map(f);

        // mappendComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
        const mappendComparing = fboolPairs =>
            (x, y) => fboolPairs.reduce(
                (ord, fb) => {
                    const f = fb[0];
                    return ord !== 0 ? (
                        ord
                    ) : fb[1] ? (
                        compare(f(x), f(y))
                    ) : compare(f(y), f(x));
                }, 0
            );

        // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
        const sortBy = (f, xs) =>
            xs.slice()
            .sort(f);

        // Sort a list by comparing the results of a key function applied to each
        // element. sortOn f is equivalent to sortBy (comparing f), but has the
        // performance advantage of only evaluating f once for each element in
        // the input list. This is called the decorate-sort-undecorate paradigm,
        // or Schwartzian transform.
        // Elements are arranged from from lowest to highest.
        // sortOn :: Ord b => (a -> b) -> [a] -> [a]
        // sortOn :: Ord b => [((a -> b), Bool)]  -> [a] -> [a]
        const sortOn = (f, xs) => {
            // Functions and matching bools derived from argument f
            // which may be a single key function, or a list of key functions
            // each of which may or may not be followed by a direction bool.
            const fsbs = unzip(
                    flatten([f])
                    .reduceRight((a, x) =>
                        typeof x === 'boolean' ? {
                            asc: x,
                            fbs: a.fbs
                        } : {
                            asc: true,
                            fbs: [
                                [x, a.asc]
                            ].concat(a.fbs)
                        }, {
                            asc: true,
                            fbs: []
                        })
                    .fbs
                ),
                [fs, bs] = [fsbs[0], fsbs[1]],
                iLast = fs.length;
            // decorate-sort-undecorate
            return sortBy(mappendComparing(
                    // functions that access pre-calculated values by position
                    // in the decorated ('Schwartzian') version of xs
                    zip(fs.map((_, i) => x => x[i]), bs)
                ), xs.map( // xs decorated with precalculated key function values
                    x => fs.reduceRight(
                        (a, g) => [g(x)].concat(a), [
                            x
                        ])))
                .map(x => x[iLast]); // undecorated version of data, post sort.
        };

        // showJSON :: a -> String
        const showJSON = x => JSON.stringify(x, null, 2);

        // unzip :: [(a,b)] -> ([a],[b])
        const unzip = xys =>
            xys.reduce(
                (a, x) => Tuple.apply(null, [0, 1].map(
                    i => a[i].concat(x[i])
                )),
                Tuple([], [])
            );

        // zip :: [a] -> [b] -> [(a, b)]
        const zip = (xs, ys) =>
            xs.slice(0, Math.min(xs.length, ys.length))
            .map((x, i) => Tuple(x, ys[i]));

        // MAIN --------------------------------------------------------------

        const
            sorted = sortOn(
                g => g.userData['ptwShape'],
                canvases[0].graphics
            ),
            inGroups = groupBy(
                (a, b) => a.userData['ptwShape'] === b.userData['ptwShape'],
                sorted
            );

        return showJSON(
            map(xs => {
                    const
                        grp = new Group(xs),
                        k = xs[0].name;
                    return (
                        grp.name = k,
                        grp.setUserData('ptwGroup', 'system'),
                        grp.locked = true,
                        k
                    );
                },
                inGroups
            )
        );
    };

    // Javascript for Automation JS Context (JXA) ----------------------------

    return Application('OmniGraffle')
        .evaluateJavascript(
            '(' + ogJSContext + ')()'
        );
})();

I’ll stop yak-shaving at this point - this version (fused groupSortOn) takes c. 16 seconds here to group 4000 shapes into 2000 groups.

(12 seconds if I close the sidebar, so that the group tree display doesn’t need updating)

Only one or two of those seconds are now spent sorting and grouping - the JSC interpreter is pretty fast - the rest seems to be the mechanics of creating and setting properties for 2000 new OmniGraffle groups, and updating the GUI.

(() => {
    'use strict';

    // OG JS Context (omniJS) -----------------------------------------------

    const ogJSContext = () => {

        // Tuple (,) :: a -> b -> (a, b)
        const Tuple = (a, b) => ({
            type: 'Tuple',
            '0': a,
            '1': b
        });

        // compare :: a -> a -> Ordering
        const compare = (a, b) => a < b ? -1 : (a > b ? 1 : 0);

        // flatten :: NestedList a -> [a]
        const flatten = t =>
            Array.isArray(t) ? (
                [].concat.apply([], t.map(flatten))
            ) : t;

        // Typical usage: groupBy(on(eq, f), xs)
        // groupBy :: (a -> a -> Bool) -> [a] -> [[a]]
        const groupBy = (f, xs) => {
            const dct = xs.slice(1)
                .reduce((a, x) => {
                    const h = a.active.length > 0 ? a.active[0] : undefined;
                    return h !== undefined && f(h, x) ? {
                        active: a.active.concat([x]),
                        sofar: a.sofar
                    } : {
                        active: [x],
                        sofar: a.sofar.concat([a.active])
                    };
                }, {
                    active: xs.length > 0 ? [xs[0]] : [],
                    sofar: []
                });
            return dct.sofar.concat(dct.active.length > 0 ? [dct.active] : []);
        };

        // map :: (a -> b) -> [a] -> [b]
        const map = (f, xs) => xs.map(f);

        // mappendComparing :: [((a -> b), Bool)] -> (a -> a -> Ordering)
        const mappendComparing = fboolPairs =>
            (x, y) => fboolPairs.reduce(
                (ord, fb) => {
                    const f = fb[0];
                    return ord !== 0 ? (
                        ord
                    ) : fb[1] ? (
                        compare(f(x), f(y))
                    ) : compare(f(y), f(x));
                }, 0
            );

        // sortBy :: (a -> a -> Ordering) -> [a] -> [a]
        const sortBy = (f, xs) =>
            xs.slice()
            .sort(f);

        // Sort and group a list by comparing the results of a key function
        // applied to each element. groupSortOn f is equivalent to
        // groupBy eq $ sortBy (comparing f),
        // but has the performance advantage of only evaluating f once for each
        // element in the input list.
        // This is a decorate-(group . sort)-undecorate pattern, as in the
        // so-called 'Schwartzian transform'.
        // Groups are arranged from from lowest to highest.
        // groupSortOn :: Ord b => (a -> b) -> [a] -> [a]
        const groupSortOn = (f, xs) => {
            // Functions and matching bools derived from argument f
            // which is a single key function
            const fsbs = unzip(
                    flatten([f])
                    .reduceRight((a, x) =>
                        typeof x === 'boolean' ? {
                            asc: x,
                            fbs: a.fbs
                        } : {
                            asc: true,
                            fbs: [
                                [x, a.asc]
                            ].concat(a.fbs)
                        }, {
                            asc: true,
                            fbs: []
                        })
                    .fbs
                ),
                [fs, bs] = [fsbs[0], fsbs[1]],
                iLast = fs.length;
            // decorate-sort-undecorate
            return groupBy(
                    (p, q) => p[0] === q[0],
                    sortBy(
                        mappendComparing(
                            // functions that access pre-calculated values by position
                            // in the decorated ('Schwartzian') version of xs
                            zip(fs.map((_, i) => x => x[i]), bs)
                        ), xs.map( // xs decorated with precalculated key function values
                            x => fs.reduceRight(
                                (a, g) => [g(x)].concat(a), [
                                    x
                                ]
                            )
                        )
                    )
                )
                .map(gp => gp.map(x => x[iLast])); // undecorated version of data, post sort.
        };

        // showJSON :: a -> String
        const showJSON = x => JSON.stringify(x, null, 2);

        // unzip :: [(a,b)] -> ([a],[b])
        const unzip = xys =>
            xys.reduce(
                (a, x) => Tuple.apply(null, [0, 1].map(
                    i => a[i].concat(x[i])
                )),
                Tuple([], [])
            );

        // zip :: [a] -> [b] -> [(a, b)]
        const zip = (xs, ys) =>
            xs.slice(0, Math.min(xs.length, ys.length))
            .map((x, i) => Tuple(x, ys[i]));

        // MAIN --------------------------------------------------------------

        return showJSON(
            map(xs => {
                    const
                        grp = new Group(xs),
                        k = xs[0].name;
                    return (
                        grp.name = k,
                        grp.setUserData('ptwGroup', 'system'),
                        grp.locked = true,
                        k
                    );
                },
                groupSortOn(
                    g => g.userData['ptwShape'],
                    canvases[0].graphics
                )
            )
        );
    };

    // Javascript for Automation JS Context (JXA) ----------------------------

    return Application('OmniGraffle')
        .evaluateJavascript(
            '(' + ogJSContext + ')()'
        );
})();