Here’s a rough draft of a test script (works with Sierra and iOS 10+ only on current OG7.4 and iOS OG 3 test builds).
If you run it on macOS from something like Script Editor, it should:
- Apply a classic Reingold Tilford ‘Tidy’ tree layout (parents centered over children) to any hierarchical diagram in an (OG7.4 test) front canvas (using the direction and Level/Peer gap settings in the built-in autolayout)
- Cycle the autolayout settings on to the next layout direction for cyclical testing of each direction
- Place in the clipboard a URL version of the script which seems to run OK on iOS OG3 test builds too.
No guarantees, and not for use with real data - just a testing toy, and may be broken soon by fixes in coming builds.
Click on disclosure triangle below to see code:
JavaScript for Automation source code, which runs omniJS code and also creates an omniJS URL
(() => {
'use strict';
// Ver .056
// Works with: OG7 7.4 test (v179.5 r290823)
// SIERRA+ macOS or iOS 10+ only - this draft is in ES6 JS
// DRAFT OF A 'TIDY' (REINGOLD TILFORD) AUTO-LAYOUT FOR NESTED DIAGRAMS
// (an alternative to the OmniGraffle Graphviz 'Dot' layout,
// in which parent nodes are not child-centered)
// Includes a JS calque of Andrew Kennedy's classic paper 'Drawing Trees'
// Journal of Functional Programming 6 (3): 527-534, May 1996
// Rough draft - purely illustrative - no claims or guarantees
// use with caution, and not on real data.
// Rob Trew June 27 2017
// GENERIC JS FUNCTIONS --------------------------------------------------
// A list of functions applied to a list of arguments
// <*> :: [(a -> b)] -> [a] -> [b]
const ap = (fs, xs) => //
[].concat.apply([], fs.map(f => //
[].concat.apply([], xs.map(x => [f(x)]))));
// show :: a -> String
const show = (...x) =>
JSON.stringify.apply(
null, x.length > 1 ? [x[0], null, x[1]] : x
);
// OMNIGRAFFLE JXA FUNCTIONS ---------------------------------------------
// evaluateOmniJS :: (Options -> OG Maybe JSON String) -> {KeyValues}
// -> Maybe JSON String
const evaluateOmniJS = (f, dctOptions) => {
const
a = Application.currentApplication(),
sa = (a.includeStandardAdditions = true, a);
ap([sa.openLocation, sa.setTheClipboardTo], //
['omnigraffle:///omnijs-run?script=' +
encodeURIComponent(
'(' + f.toString() + ')(' +
(dctOptions && Object.keys(dctOptions)
.length > 0 ? show(dctOptions) : '') + ')'
)
]);
// Possible harvest of return value from canvasbackground.userData
const
og = Application('OmniGraffle'),
ws = (og.activate(), og.windows.where({
_not: [{
name: {
_beginsWith: 'Automation Console'
}
}]
})),
mw = ws.length > 0 ? {
just: ws.at(0),
nothing: false
} : {
nothing: true
},
mResult = mw.nothing ? mw : (() => {
const v = mw.just.canvas()
.canvasbackground.userDataItems.byName('omniJSON')
.value()
return (v === undefined || v === null) ? ({
nothing: true,
msg: "No JSON found in " +
".canvasbackground.userDataItems.byName('omniJSON')"
}) : {
just: v,
nothing: false
};
})();
return mResult.nothing ? mResult : (
mw.just.canvas()
.canvasbackground.userDataItems.byName('omniJSON')
.value = null,
mResult
);
};
// ogFrontDoc :: {useExisting : Bool, templateName: String} -> OG.Document
const ogFrontDoc = dctOptions => {
const
options = dctOptions || {},
optTemplate = options.templateName,
og = Application('OmniGraffle'),
xs = og.availableTemplates(),
strTemplate = (optTemplate && elem(optTemplate, xs)) ? (
optTemplate
) : xs[0],
ds = og.documents,
d = options.useExisting && ds.length > 0 ? (
ds.at(0)
) : (() => {
return (
ds.push(og.Document({
template: strTemplate
})),
ds.at(0)
);
})();
return (
og.activate(),
d
);
};
// OMNIJS FUNCTIONS to be run with evaluateOmniJS() (above) -------------
// tidyLayout :: Options -> OG ()
const tidyLayout = options => {
// GENERIC FUNCTIONS -------------------------------------------------
// (++) :: [a] -> [a] -> [a]
const append = (xs, ys) => xs.concat(ys);
// concat :: [[a]] -> [a] | [String] -> String
const concat = xs =>
xs.length > 0 ? (() => {
const unit = typeof xs[0] === 'string' ? '' : [];
return unit.concat.apply(unit, xs);
})() : [];
// cons :: a -> [a] -> [a]
const cons = (x, xs) => [x].concat(xs);
// 2 or more arguments
// curry :: Function -> Function
const curry = (f, ...args) => {
const go = xs => xs.length >= f.length ? (f.apply(null, xs)) :
function () {
return go(xs.concat(Array.from(arguments)));
};
return go([].slice.call(args, 1));
};
// elem :: Eq a => a -> [a] -> Bool
const elem = (x, xs) => xs.indexOf(x) !== -1;
// elemIndex :: Eq a => a -> [a] -> Maybe Int
const elemIndex = (x, xs) => {
const i = xs.indexOf(x);
return {
nothing: i === -1,
just: i
};
};
// foldl :: (b -> a -> b) -> b -> [a] -> b
const foldl = (f, a, xs) => xs.reduce(f, a);
// fst :: [a, b] -> a
const fst = pair => pair.length === 2 ? pair[0] : undefined;
// isNull :: [a] | String -> Bool
const isNull = xs =>
Array.isArray(xs) || typeof xs === 'string' ? (
xs.length < 1
) : undefined;
// log :: a -> IO ()
const log = (...args) => {
console.log(
args
.map(show)
.join(' -> ')
);
};
// map :: (a -> b) -> [a] -> [b]
const map = (f, xs) => xs.map(f);
// max :: Ord a => a -> a -> a
const max = (a, b) => b > a ? b : a;
// maximumBy :: (a -> a -> Ordering) -> [a] -> a
const maximumBy = (f, xs) =>
xs.reduce((a, x) => a === undefined ? x : (
f(x, a) > 0 ? x : a
), undefined);
// min :: Ord a => a -> a -> a
const min = (a, b) => b < a ? b : a;
// negate :: Num a => a -> a
const negate = n => -n;
// nextInCycleMay :: [a] -> a -> Maybe a
const nextInCycleMay = (xs, x) => {
const mbIndex = elemIndex(x, xs);
return mbIndex.nothing ? (
isNull(xs) ? mbIndex : {
just: xs[0]
}
) : ({
just: takeCycle(mbIndex.just + 2, xs)[mbIndex.just + 1],
nothing: false
});
};
// read :: Read a => String -> a
const read = s => JSON.parse(s);
// replicate :: Int -> a -> [a]
const replicate = (n, x) =>
Array.from({
length: n
}, () => x);
// reverse :: [a] -> [a]
const reverse = xs =>
typeof xs === 'string' ? (
xs.split('')
.reverse()
.join('')
) : xs.slice(0)
.reverse();
// show :: a -> String
const show = (...x) =>
JSON.stringify.apply(
null, x.length > 1 ? [x[0], null, x[1]] : x
);
// snd :: (a, b) -> b
const snd = tpl => Array.isArray(tpl) ? tpl[1] : undefined;
// take :: Int -> [a] -> [a]
const take = (n, xs) => xs.slice(0, n);
// First n members of a repeating cycle of xs
// takeCycle :: Int -> [a] -> [a]
const takeCycle = (n, xs) => {
const lng = xs.length;
return take(n,
(lng >= n ? xs : concat(replicate(Math.ceil(n / lng), xs)))
);
};
// unconsMay :: [a] -> Maybe (a, [a])
const unconsMay = xs => xs.length > 0 ? {
just: [xs[0], xs.slice(1)],
nothing: false
} : {
nothing: true
};
// unzip :: [(a,b)] -> ([a],[b])
const unzip = xys =>
xys.reduceRight(([xs, ys], [x, y]) => [
[x].concat(xs), [y].concat(ys)
], [
[],
[]
]);
// zipWith :: (a -> b -> c) -> [a] -> [b] -> [c]
const zipWith = (f, xs, ys) =>
Array.from({
length: Math.min(xs.length, ys.length)
}, (_, i) => f(xs[i], ys[i]));
// TEXT NEST FUNCTIONS -----------------------------------------------
// textNest :: OGoutlineNodes -> Node {text: String, nest:[Node]}
const textNest = xs => {
const go = (xs, iLevel) =>
xs.length ? xs.map(x => {
const
g = x.graphic,
rect = g.geometry;
return {
id: g.id,
level: iLevel,
text: g.text,
xywh: ['x', 'y', 'width', 'height']
.reduce((a, k) => (a[k] = rect[k], a), {}),
nest: x.children.length > 0 ? (
go(x.children, iLevel + 1)
) : []
};
}) : [];
return go(xs, 1);
};
// Level order iteration, collecting values by supplied keys
// levelOrderTraversal :: [String] -> Node -> [[a]]
const levelOrderTraversal = (ks, dctNode) => {
const go = queue => {
const mbht = unconsMay(queue);
return mbht.nothing ? [] : (() => {
const [h, t] = mbht.just;
return cons(
foldl((a, k) => (a[k] = h[k], a), {}, ks),
go(isNull(h.nest) ? (
t
) : append(t, h.nest)) // Nest deferred to later.
);
})();
};
return go([dctNode]);
};
// levelHeights :: Bool -> Node -> Dictionary
const levelHeights = (blnHoriz, dctNode) => {
const k = blnHoriz ? 'width' : 'height';
return foldl((a, x) => {
const level = x.level;
return (a[level] = max(a[level] || 0, x.xywh[k]), a)
}, {}, levelOrderTraversal(['level', 'xywh'], dctNode));
};
// CANVAS ------------------------------------------------------------
const cnv = document.windows[0].selection.canvas;
// LAYOUT DIRECTION AND GAP SETTINGS ---------------------------------
// layoutSettings :: OG () ->
// {direction: String, levelGap: Real, peerGap: Real}
const layoutSettings = blnCycle => {
const cnv = document.windows[0].selection.canvas; //??? remove
// Temporary fix for a glitch in the Enum
// tmp :: String -> String
const tmp = k => ({
Left: 'Top',
Top: 'Bottom',
Right: 'Left',
Bottom: 'Right'
})[k],
// directionName :: OG HierarchicalDirection -> String
directionName = d =>
d < HierarchicalDirection[tmp('Right')] ? (
d < HierarchicalDirection[tmp('Top')] ? (
'Left'
) : 'Top'
) : d < HierarchicalDirection[tmp('Bottom')] ? (
'Right'
) : 'Bottom',
layout = cnv.layoutInfo;
return (
layout.automaticLayout = false, // Lest it undo ours.
layout.type = LayoutType.Hierarchical, // Remove ???
// DIRECTION CYCLING FOR TESTING --- /// REMOVE ???
blnCycle && (layout.direction =
HierarchicalDirection[tmp(nextInCycleMay(
['Top', 'Bottom', 'Left', 'Right'],
directionName(layout.direction))
.just)]), {
direction: directionName(layout.direction),
levelGap: layout.rankSeparation * 72, // Points per inch.
peerGap: layout.objectSeparation * 72,
type: layout.type
});
};
// A positioned tree with its root at zero.
// (A standard textNest decorated with geometry, id, level index)
// layoutDesign :: Node {text: String, nest:[Node]} ->
// Node {text: String, nest:[Node], x:Real}
const layoutDesign = (keyDimension, peerGap, dctNode) => {
// REINGOLD TILFORD CODE ADAPTED FROM KENNEDY --------------------
// TREE-DRAWING FUNCTIONS ----------------------------------------
// A JavaScript adaption of Andrew Kennedy's classic (ML) paper
// 'Drawing Trees'
// Journal of Functional Programming 6 (3): 527-534, May 1996
// This version mainly differs from Kennedy in allowing for nodes
// of variable height and width.
// keyDimension is either 'width' (or vertical trees)
// or 'height' for horizontal trees.
// peerGap should be in the same units as height/width
// X POSITIONS - relative to parents
// The x position of a node is relative to its parent
// (y positions are properties of nesting layers - ignored here)
// A sub-tree moves with any x-delta applied to its parent node
// Node {text: String, nest:[Node], x:Real}
// moveTree :: Real -> Node -> Node
const moveTree = (rd, dctNode) =>
Object.assign({}, dctNode, {
rx: dctNode.rx + rd
});
// EXTENTS - Lists of pairs of x positions
// The head of the list is the pair for the top level of the tree,
// and thus downward.
// Each pair holds the left (mininum) and right (maximum) x values
// for its level
// Extent [(leftMost :: Real, rightMost :: Real)]
// Applying an x-axis delta to all the pairs in an extent:
// moveExtent :: Real -> Extent -> Extent
const moveExtent = (rd, pairs) =>
map(([p, q]) => [p + rd, q + rd], pairs);
// The left most positions of one extent,
// combined with the rightmost positions of an adject extent:
// merge :: Extent -> Extent -> Extent
const merge = (ps, qs) => {
const
mbp = unconsMay(ps),
mbq = mbp.nothing ? mbp : unconsMay(qs);
return mbp.nothing ? (
qs
) : mbq.nothing ? (
ps
) : (() => {
const [ph, pt] = mbp.just, [qh, qt] = mbq.just;
return cons([fst(ph), snd(qh)], merge(pt, qt));
})();
};
// Merging a pair of adjacent extents lifting to merging
// a longer series of adjacent extents:
// mergeList :: [Extent] -> Extent
const mergeList = es => foldl(merge, [], es);
// What is the *minimum* distance between two given extents ?
// (Proliferating descendants may push them further apart)
// fit :: Extent -> Extent -> Real
const fit = (ps, qs) => {
const
mbp = unconsMay(ps),
mbq = mbp.nothing ? mbp : unconsMay(qs);
return (mbp.nothing || mbq.nothing) ? 0 : (() => {
const [ph, pt] = mbp.just, [qh, qt] = mbq.just;
return max((snd(ph) - fst(qh)), fit(pt, qt));
})();
};
// How far leftward could each extent go ?
// fitlistl :: [Extent] -> [Real]
const fitlistl = es => {
const fl = (a, ees) => {
const mbees = unconsMay(ees);
return mbees.nothing ? [] : (() => {
const [e, es] = mbees.just,
d = fit(a, e);
return cons(d, fl(merge(a, moveExtent(d, e)), es));
})();
};
return fl([], es);
};
// How far rightward could each extent go ?
// fitlistr :: [Extent] -> [Real]
const fitlistr = es => {
const fr = (a, ees) => {
const mbees = unconsMay(ees);
return mbees.nothing ? [] : (() => {
const [e, es] = mbees.just,
d = negate(fit(e, a));
return cons(d, fr(merge(moveExtent(d, e), a), es));
})();
}
return reverse(fr([], reverse(es)));
};
// What are the mean positions,
// mid way between left and right constraints,
// for each extent ?
// fitList :: [Extent] -> [Real]
const fitList = es =>
zipWith(
(x, y) => (x + y) / 2,
fitlistl(es),
fitlistr(es)
);
// design :: Node -> (Node, [Extent])
const design = node => {
const
w2 = (node.xywh[keyDimension] + peerGap) / 2,
[trees, extents] = unzip(
map(design, node.nest)
),
positions = fitList(extents),
resultExtent = cons([-w2, w2], mergeList(
zipWith(moveExtent, positions, extents)
)),
resultTree = Object.assign({}, node, {
nest: zipWith(moveTree, positions, trees),
rx: 0
});
return [resultTree, resultExtent];
};
return fst(design(dctNode));
};
// UPDATED RENDER ----------------------------------------------------
// Subtree positions updated from geometry in a given key of nodes
// positionUpdated :: Canvas -> String -> Bool, Node -> Node
const positionsUpdated = curry((cnv, key, blnAcross, dctNode) => {
const
dct = dctNode[key],
[x, y, width, height] = map(
k => dct[k], ['x', 'y', 'width', 'height']
),
g = cnv.graphicWithId(dctNode.id),
mgs = g.magnets;
// Make sure that each node has the right two magnets for
// direction of tree growth. (NS for vertical, EW for horizontal)
return (
(g.magnets = (blnAcross ? (
[new Point(1.00, 0.00), new Point(-1.00, 0.00)]
) : [new Point(0.00, 1.00), new Point(0.00, -1.00)])),
g.geometry = new Rect(x, y, width, height),
map(positionsUpdated(cnv, key, blnAcross), dctNode.nest),
dctNode
);
});
// newPositions :: Dictionary -> Dictionary ->
// Dictionary -> Node -> Node
const newPositions = (settings, dctLevels, posn, dctNode) => {
// Direction, levelGap, peerGap
const
blnVert = elem(settings.direction, ['Top', 'Bottom']),
blnMarked = elem(settings.direction, ['Bottom', 'Right']),
[keyOrth, keyAxial, dimOrth, dimAxial] = blnVert ? (
['width', 'height', 'x', 'y']
) : ['height', 'width', 'y', 'x'],
cs = dctNode.nest,
iLevel = dctNode.level,
rankDepth = dctLevels[iLevel];
return {
id: dctNode.id,
level: iLevel,
text: dctNode.text,
rx: dctNode.rx,
geo: posn,
nest: cs.length < 1 ? [] : (() => {
const
origin = posn[dimOrth] + (posn[keyOrth] / 2),
pLevelTop = blnMarked ? (
((posn[dimAxial] + posn[keyAxial]) - rankDepth) -
settings.levelGap
) : posn[dimAxial] + rankDepth + settings.levelGap;
return map(c => {
// Updated geo and recursively updated descendants.
const
cxywh = c.xywh,
w = cxywh[keyOrth],
h = cxywh[keyAxial];
return newPositions(settings, dctLevels, {
x: blnVert ? (
(origin + c.rx) - (w / 2)
) : (pLevelTop - (blnMarked ? h : 0)),
y: blnVert ? (
pLevelTop - (blnMarked ? h : 0)
) : ((origin + c.rx) - (w / 2)),
rx: c.rx,
width: cxywh.width,
height: cxywh.height
}, c);
}, cs)
})()
};
};
// MAIN --------------------------------------------------------------
console.clear();
const
lstTextNest = textNest(cnv.outlineRoot.children),
// Direction, levelGap, peerGap
settings = layoutSettings(), // TEST VERSION ROTATES DIRECTION ???
blnHoriz = elem(settings.direction, ['Left', 'Right']),
jsoLayout = layoutDesign(
blnHoriz ? 'height' : 'width',
settings.peerGap,
isNull(lstTextNest) ? {
text: 'Root',
nest: []
} : lstTextNest[0]
);
const strJSON = show(
positionsUpdated(
cnv, 'geo', blnHoriz, newPositions(
settings,
levelHeights(blnHoriz, jsoLayout),
jsoLayout.xywh,
jsoLayout
)
), 2
);
// WHILE TESTING, ROTATE TO NEXT DIRECTION ??? REMOVE LATER
const dctNew = layoutSettings(true);
return (
cnv.background.setUserData('omniJSON', strJSON),
strJSON
);
};
// TEST: APPLY REINGOLD TILFORD 'TIDY' AUTOLAYOUT TO FRONT CANVAS
// USING DIRECTION AND LEVEL/PEER GAP SETTINGS IN OG7 --------------------
// THIS TEST VERSION ALSO MOVES THE OG7 LAYOUT DIRECTION ON TO THE
// NEXT POSITION FOR CYCLICAL TESTING AROUND THE FOUR QUADRANTS
const
og = Application('OmniGraffle'),
d = ogFrontDoc({
useExisting: true
}),
a = Application.currentApplication(),
sa = (a.includeStandardAdditions = true, a);
// const strClip = (
const strJSON = evaluateOmniJS(tidyLayout, {
k: "someOption" // Not used
});
// );
// );
// TESTING -- RETURN FOCUS TO THE CODE EDITOR
// (Atom, running the Script plugin to execute JXA)
// Application('Atom')
// .activate(); // Remove after testing ???
// sa.setTheClipboardTo(strClip);
// return strClip;
//return strJSON;
})();