howardjones/network-weathermap

View on GitHub
lib/Weathermap/Editor/Editor.php

Summary

Maintainability
F
1 wk
Test Coverage
<?php
// PHP Weathermap 0.97b
// Copyright Howard Jones, 2005-2012 howie@thingy.com
// http://www.network-weathermap.com/
// PHP Weathermap is licensed under the MIT License, see LICENSE file for more information.
namespace Weathermap\Editor;

use Weathermap\Core\Map;
use Weathermap\Core\MapNode;
use Weathermap\Core\MapLink;
use Weathermap\Core\Rectangle;
use Weathermap\Core\WeathermapInternalFail;
use Weathermap\Core\Point;
use Weathermap\Core\MathUtility;
use Weathermap\Core\Target;
use Weathermap\Core\StringUtility;
use Weathermap\UI\UIBase;

/** Wrapper API around Map to provide the relevant operations to manipulate
 *  the map contents that an editor will need, without it needing to see inside the map object.
 *  (a second class, EditorUI, is concerned with the actual presentation of the supplied editor)
 */
class Editor
{
    /** @var Map $map */
    public $map;
    /** @var string $mapFileName */
    public $mapFileName;

    public function __construct()
    {
        $this->map = null;
    }

    public function newConfig()
    {
        $this->map = new Map();
        $this->map->context = "editor";
        $this->mapFileName = "untitled";
    }

    public function loadConfig($fileName)
    {
        $this->map = new Map();
        $this->map->context = 'editor';
        $this->map->readConfig($fileName);
        $this->mapFileName = $fileName;
    }

    /**
     * Save the map config file.
     *
     * Optionally, save to a different file from the one loaded.
     *
     * @param string $fileName
     */
    public function saveConfig($fileName = "")
    {
        if ($fileName != "") {
            $this->mapFileName = $fileName;
        }
        $this->map->writeConfig($this->mapFileName);
    }

    /**
     * Return the config that would have been saved. Mainly for tests.
     *
     */
    public function getConfig()
    {
        return $this->map->getConfig();
    }

    public function getItemConfig($itemType, $itemName)
    {
        if ($itemType == 'node' && $this->map->nodeExists($itemName)) {
            $node = $this->map->getNode($itemName);
            return $node->getConfig();
        }

        if ($itemType == 'link' && $this->map->linkExists($itemName)) {
            $link = $this->map->getlink($itemName);
            return $link->getConfig();
        }

        return false;
    }

    public function addNode($x, $y, $nodeName = "", $template = "DEFAULT")
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $newNodeName = null;

        // Generate a node name for ourselves if one wasn't supplied
        if ($nodeName == "") {
            $newNodeName = sprintf("node%05d", time() % 10000);
            while ($this->map->nodeExists($newNodeName)) {
                $newNodeName .= "a";
            }
        } else {
            $newNodeName = $nodeName;
        }

        // Check again - if they are specifying a name, it's possible for it to exist
        if (!$this->map->nodeExists($newNodeName)) {
            $newNode = new MapNode($newNodeName, $template, $this->map);

            $newNode->setPosition(new Point($x, $y));
            $newNode->setDefined($this->map->configfile);

            // only insert a label if there's no LABEL in the DEFAULT node.
            // otherwise, respect the template.
            $default = $this->map->getNode("DEFAULT");
            $defaultDefault = $this->map->getNode(":: DEFAULT ::");

            if ($default->label == $defaultDefault->label) {
                $newNode->label = "Node";
            }

            $this->map->addNode($newNode);
            $log = "added a node called $newNodeName at $x,$y to $this->mapFileName";
            $success = true;
        } else {
            $log = "Requested node name already exists";
            $success = false;
        }

        return array($newNodeName, $success, $log);
    }

    public function isLoaded()
    {
        return !is_null($this->map);
    }

    /**
     * moveNode - move a node, taking into account any relative nodes, and any links that
     * join to it, dealing with VIAs in an attractive way.
     *
     * @param string $nodeName
     * @param number $newX
     * @param number $newY
     * @return array
     * @throws WeathermapInternalFail
     */
    public function moveNode($nodeName, $newX, $newY)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // if the node doesn't exist, nothing will be changing
        if (!$this->map->nodeExists($nodeName)) {
            return array(0, 0, 0, 0);
        }

        $movingNode = $this->map->getNode($nodeName);

        $affectedNodes = array();
        $affectedLinks = array();
        $affectedNodes [] = $movingNode->name;

        // This is a complicated bit. Find out if this node is involved in any
        // links that have VIAs. If it is, we want to rotate those VIA points
        // about the *other* node in the link
        foreach ($this->map->links as $link) {
            if (!$link->isTemplate()
                && count($link->viaList) > 0
                && (
                    ($link->endpoints[0]->node->name == $nodeName)
                    || ($link->endpoints[1]->node->name == $nodeName)
                )
            ) {
                $affectedLinks[] = $link->name;
                $pivot = null;

                // get the other node from us
                if ($link->endpoints[0]->node->name == $nodeName) {
                    $pivot = $link->endpoints[1]->node;
                }

                if ($link->endpoints[1]->node->name == $nodeName) {
                    $pivot = $link->endpoints[0]->node;
                }

                // this is a weird special case, but it is possible, with link offsets
                // if the link starts and ends on this node, translate any VIAs
                if (($link->endpoints[0]->node->name == $nodeName) && ($link->endpoints[1]->node->name == $nodeName)) {
                    $dx = $link->endpoints[0]->node->x - $newX;
                    $dy = $link->endpoints[0]->node->y - $newY;

                    for ($count = 0; $count < count($link->viaList); $count++) {
                        $link->viaList[$count][0] = $link->viaList[$count][0] - $dx;
                        $link->viaList[$count][1] = $link->viaList[$count][1] - $dy;
                    }
                } else {
                    $pivotX = $pivot->x;
                    $pivotY = $pivot->y;

                    $newPoint = new Point($newX, $newY);
                    /** @var Point $pivotPoint */
                    $pivotPoint = $pivot->getPosition();
                    $movingPoint = $movingNode->getPosition();

                    $oldVector = $pivotPoint->vectorToPoint($movingPoint);
                    $newVector = $pivotPoint->vectorToPoint($newPoint);

                    $oldAngle = $oldVector->getAngle();
                    $newAngle = $newVector->getAngle();
                    $oldLength = $oldVector->length();
                    $newLength = $newVector->length();

                    // the geometry stuff uses a different point format, helpfully
                    $points = array();
                    foreach ($link->viaList as $via) {
                        $points[] = $via[0];
                        $points[] = $via[1];
                    }

                    $scaleFactor = $newLength / $oldLength;

                    // rotate so that link is along the axis
                    MathUtility::rotateAboutPoint($points, $pivotX, $pivotY, deg2rad($oldAngle));
                    // do the scaling in here
                    for ($count = 0; $count < (count($points) / 2); $count++) {
                        $basex = ($points[$count * 2] - $pivotX) * $scaleFactor + $pivotX;
                        $points[$count * 2] = $basex;
                    }
                    // rotate back so that link is along the new direction
                    MathUtility::rotateAboutPoint($points, $pivotX, $pivotY, deg2rad(-$newAngle));

                    // now put the modified points back into the vialist again
                    $viaCount = 0;
                    $count = 0;
                    foreach ($points as $p) {
                        // skip a point if it positioned relative to a node. Those shouldn't be rotated (well, IMHO)
                        if (!isset($link->viaList[$viaCount][2])) {
                            $link->viaList[$viaCount][$count] = $p;
                        }
                        $count++;
                        if ($count == 2) {
                            $count = 0;
                            $viaCount++;
                        }
                    }
                }
            }
        }

        $movingNode->x = $newX;
        $movingNode->y = $newY;


        // TODO: need to redraw image here, to get correct imagemap?
        // TODO: Also, recalculate any relative positioned nodes? (doesn't matter with current editor, but isn't intuitive)

        // Find affected Nodes. This is recursive - we could be moving a node that positions another, that in turn
        // positions another!
        $changes = 0;
        $iterations = 0;
        do {
            foreach ($this->map->nodes as $node) {
                if (in_array($node->positionRelativeTo, $affectedNodes) && !in_array($node->name, $affectedNodes)) {
                    $node->relativePositionResolved = false;
                    $affectedNodes [] = $node->name;
                    $changes++;
                }
            }
            $iterations++;
        } while ($changes > 0 && $iterations < 1000);

        return array(count($affectedNodes), count($affectedLinks), $affectedNodes, $affectedLinks);
    }

    public function updateNode($nodeName, $params)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if (!$this->map->nodeExists($nodeName)) {
            return;
        }

        $node = $this->map->getNode($nodeName);

        // first check if there's a rename...
        if (array_key_exists('new_name', $params)) {
            if ($nodeName != $params['new_name']) {
                $nodeName = $this->renameNode($nodeName, $params['new_name']);
            }

            $node = $this->map->getNode($nodeName);
        }

        if (array_key_exists('x', $params) && array_key_exists('y', $params)) {
            $node->setPosition(new Point($params['x'], $params['y']));

        }

        if (array_key_exists('label', $params)) {
            $node->label = $params['label'];
        }

        // AICONs mess this up, because they're not fully supported by the editor, but it can still break them
        if (array_key_exists('iconfilename', $params) && $params['iconfilename'] != '--AICON--') {
            $node->iconfile = stripslashes($params['iconfilename']);
        }

        if (array_key_exists('infourl', $params)) {
            $node->infourl[IN] = $params['infourl'];
        }

        if (array_key_exists('hover', $params)) {
            $urls = preg_split('/\s+/', $params['hover'], -1, PREG_SPLIT_NO_EMPTY);
            $node->overliburl[IN] = $urls;
            $node->overliburl[OUT] = $urls;
        }

        if (array_key_exists('lock_to', $params)) {
            if ($params['lock_to'] == "") {
                $node->positionRelativeTo = "";
            } else {
                if ($this->map->nodeExists($params['lock_to'])) {
                    $anchor = $this->map->getNode($params['lock_to']);

                    $node->positionRelativeTo = $anchor->name;
                    $node->originalX = $node->x - $anchor->x;
                    $node->originalY = $node->y - $anchor->y;
                }
            }
        }
    }

    public function renameNode($oldName, $newName)
    {
        if (!$this->map->nodeExists($oldName)) {
            return $oldName;
        }

        if ($this->map->nodeExists($newName)) {
            return $oldName;
        }

        // we need to rename the node first.
        $newNode = $this->map->getNode($oldName);
        $newNode->name = $newName;

        $this->map->nodes[$newName] = $newNode;
        unset($this->map->nodes[$oldName]);

        // find the references elsewhere to the old node name.
        // First, relatively-positioned NODEs
        foreach ($this->map->nodes as $movingNode) {
            if ($movingNode->positionRelativeTo == $oldName) {
                $movingNode->positionRelativeTo = $newName;
            }
        }

        // Next, LINKs that use this NODE as an end.
        foreach ($this->map->links as $link) {
            if (!$link->isTemplate()) {
                if ($link->endpoints[0]->node->name == $oldName) {
                    print "End[0] matches $oldName";
                    $link->endpoints[0]->node = $newNode;
                }
                if ($link->endpoints[1]->node->name == $oldName) {
                    print "End[1] matches $oldName";
                    $link->endpoints[1]->node = $newNode;
                }
                // while we're here, VIAs can also be relative to a NODE,
                // so check if any of those need to change
                $n = 0;
                foreach ($link->viaList as $via) {
                    if (isset($via[2]) && $via[2] == $oldName) {
                        $link->viaList[$n][2] = $newName;
                    }
                    $n++;
                }
            }
        }

        return $newName;
    }

    public function replaceNodeConfig($nodeName, $newConfig)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // if the node doesn't exist, nothing will be changing
        if (!$this->map->nodeExists($nodeName)) {
            return false;
        }

        $node = $this->map->getNode($nodeName);
        $node->replaceConfig($newConfig);
        return true;
    }

    public function replaceLinkConfig($linkName, $newConfig)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // if the link doesn't exist, nothing will be changing
        if (!$this->map->linkExists($linkName)) {
            return false;
        }

        $link = $this->map->getLink($linkName);
        $link->replaceConfig($newConfig);
        return true;
    }

    public function deleteNode($nodeName)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $log = "";
        $affectedNodes = array();
        $affectedLinks = array();

        if (isset($this->map->nodes[$nodeName])) {
            $affectedNodes[] = $nodeName;

            $log = "delete node " . $nodeName;

            foreach ($this->map->links as $link) {
                if (isset($link->endpoints[0]->node)) {
                    if (($nodeName == $link->endpoints[0]->node->name) || ($nodeName == $link->endpoints[1]->node->name)) {
                        $affectedLinks[] = $link->name;
                        unset($this->map->links[$link->name]);
                    }
                }
            }

            unset($this->map->nodes[$nodeName]);
        }
        // TODO - look for relative positioned nodes, and un-relative them

        $nNodes = count($affectedNodes);
        $nLinks = count($affectedLinks);
        return array($nNodes, $nLinks, $affectedNodes, $affectedLinks, $log);
    }

    public function cloneNode($sourceNodeName, $targetName = "", $orFail = false)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if ($this->map->nodeExists($sourceNodeName)) {
            $log = "cloned node " . $sourceNodeName;
            $sourceNode = $this->map->nodes[$sourceNodeName];

            // Try to use the requested name, if possible, and if specified
            $newNodeName = ($targetName != "" ? $targetName : $sourceNodeName);

            if ($targetName != "" && $orFail && $this->map->nodeExists($newNodeName)) {
                return array(false, null, "Requested name already exists");
            }

            if (isset($this->map->nodes[$newNodeName])) {
                $newNodeName = $sourceNodeName;
                do {
                    $newNodeName = $newNodeName . "_copy";
                } while (isset($this->map->nodes[$newNodeName]));
            }

            $log .= " into $newNodeName";

            $newNode = new MapNode($newNodeName, $sourceNode->template, $this->map);
            $newNode->copyFrom($sourceNode);

            # CopyFrom skips this one, because it's also the function used by template inheritance
            # - but for Clone, we DO want to copy the template too
            //  $node->template = $sourceNode->template;

            $now = $newNode->getPosition();
            $now->translate(30, 30);
            $newNode->setPosition($now);
            $newNode->setDefined($this->map->configfile);

            $this->map->addNode($newNode);

            return array(true, $newNodeName, $log);
        }

        return array(false, null, "Request source does not exist");
    }

    public function addLink($nodeName1, $nodeName2, $linkName = "", $template = "DEFAULT")
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $success = false;
        $log = "";
        $newLinkName = "";

        // XXX - do we care if node1==node2?
        if ($nodeName1 != $nodeName2 && $this->map->nodeExists($nodeName1) && $this->map->nodeExists($nodeName2)) {
            $newLinkName = ($linkName != "" ? $linkName : "$nodeName1-$nodeName2");

            // make sure the link name is unique. We can have multiple links between
            // the same nodes, these days
            while (array_key_exists($newLinkName, $this->map->links)) {
                $newLinkName .= "a";
            }

            $newLink = new MapLink($newLinkName, $template, $this->map);
            $newLink->definedIn = $this->map->configfile;

            $newLink->setEndNodes($this->map->getNode($nodeName1), $this->map->getNode($nodeName2));

            $this->map->addLink($newLink);

            $success = true;
            $log = "created link $newLinkName between $nodeName1 and $nodeName2";
        }

        return array($newLinkName, $success, $log);
    }

    public function updateLink($linkName, $params)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if ($this->map->linkExists($linkName)) {
            $link = $this->map->getLink($linkName);

            // Now deal with params

            $link->infourl[IN] = $params['infourl'];
            $link->infourl[OUT] = $params['infourl'];
            $urls = preg_split('/\s+/', $params['hover'], -1, PREG_SPLIT_NO_EMPTY);
            $link->overliburl[IN] = $urls;
            $link->overliburl[OUT] = $urls;

            $link->commentOffsets[IN] = intval($params['commentpos_in']);
            $link->commentOffsets[OUT] = intval($params['commentpos_out']);

            $link->comments[IN] = $params['comment_in'];
            $link->comments[OUT] = $params['comment_out'];


            $link->maxValuesConfigured[IN] = $params['bandwidth_in'];
            $link->maxValuesConfigured[OUT] = $params['bandwidth_out'];
            $link->maxValues[IN] = StringUtility::interpretNumberWithMetricSuffixOrNull(
                $params['bandwidth_in'],
                $this->map->kilo
            );
            $link->maxValues[OUT] = StringUtility::interpretNumberWithMetricSuffixOrNull(
                $params['bandwidth_out'],
                $this->map->kilo
            );

            $targets = preg_split('/\s+/', $params['target'], -1, PREG_SPLIT_NO_EMPTY);
            $newTargetList = array();

            foreach ($targets as $target) {
                $newTargetList[] = new Target($target, "", 0);
            }
            $link->targets = $newTargetList;

            $link->width = floatval($params['width']);
        }
    }

    public function deleteLink($linkName)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if ($this->map->linkExists($linkName)) {
            unset($this->map->links[$linkName]);
            return true;
        }
        return false;
    }

    public function renameLink($oldName, $newName)
    {
        if (!$this->map->linkExists($oldName)) {
            return $oldName;
        }

        if ($this->map->linkExists($newName)) {
            return $oldName;
        }

        // we need to rename the link first.
        $newLink = $this->map->getLink($oldName);
        $newLink->name = $newName;

        $this->map->links[$newName] = $newLink;
        unset($this->map->links[$oldName]);

        return $newName;
    }

    /**
     * cloneLink - create a copy of an existing link
     * Not as useful as cloneNode, but still sometimes handy.
     *
     * @param string $sourceName
     * @param string $targetName
     * @throws WeathermapInternalFail
     */
    public function cloneLink($sourceName, $targetName = "")
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        throw new WeathermapInternalFail("unimplemented");
    }

    /**
     * setLinkVia - simple-minded add/replacement of a single VIA for a link.
     * Should be replaced by addLinkVia with intelligent handling of multiple VIAs
     *
     * @param string $linkName
     * @param number $x
     * @param number $y
     * @return bool - successful or not
     * @throws WeathermapInternalFail
     */
    public function setLinkVia($linkName, $x, $y)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if ($this->map->linkExists($linkName)) {
            $link = $this->map->getLink($linkName);
            $link->viaList = array(array(0 => $x, 1 => $y));

            return true;
        }
        return false;
    }

    public function clearLinkVias($linkName)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        if ($this->map->linkExists($linkName)) {
            $link = $this->map->getLink($linkName);
            $link->viaList = array();

            return true;
        }
        return false;
    }

    public function tidyLink($linkName)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // draw a map and throw it away, to calculate all the bounding boxes
        $this->map->drawMap('null');
        $link = $this->map->getLink($linkName);
        $this->tidyOneLink($link);
    }

    /**
     * tidyOneLink - change link offsets so that link is horizonal or vertical, if possible.
     *  if not possible, change offsets to the closest facing compass points
     *
     * @param MapLink $link - the link to tidy
     * @param int $linknumber - if this is part of a group, which number in the group
     * @param int $linktotal - if this is part of a group, how many total in the group
     * @param bool $ignoreTidied - whether to take notice of the "_tidied" hint
     */
    private function tidyOneLink($link, $linknumber = 1, $linktotal = 1)
    {
        if ($link->isTemplate()) {
            return;
        }

        $nodeA = $link->endpoints[0]->node;
        $nodeB = $link->endpoints[1]->node;

        // Update TODO: if the nodes are already directly left/right or up/down, then use compass-points, not pixel offsets
        // (e.g. N90) so if the label changes, they won't need to be re-tidied

        // First bounding box in the node's boundingbox array is the icon, if there is one, or the label if not.
        $boundingBoxA = $nodeA->boundingboxes[0];
        $boundingBoxB = $nodeB->boundingboxes[0];

        // figure out if they share any x or y coordinates
        $xOverlaps = $this->rangeOverlaps(
            array($boundingBoxA[0], $boundingBoxA[2]),
            array($boundingBoxB[0], $boundingBoxB[2])
        );
        $yOverlaps = $this->rangeOverlaps(
            array($boundingBoxA[1], $boundingBoxA[3]),
            array($boundingBoxB[1], $boundingBoxB[3])
        );

        $aXOffset = 0;
        $aYOffset = 0;
        $bXOffset = 0;
        $bYOffset = 0;

        // if they are side by side, and there's some common y coords, make link horizontal
        if (!$xOverlaps && $yOverlaps) {
            list($aXOffset, $bXOffset) = $this->tidySimpleDimension(
                $boundingBoxA,
                $boundingBoxB,
                $nodeA,
                $nodeB,
                0,
                "x"
            );
            list($aYOffset, $bYOffset) = $this->tidyComplexDimension(
                $boundingBoxA,
                $boundingBoxB,
                $nodeA,
                $nodeB,
                1,
                "y",
                $linknumber,
                $linktotal
            );
        }

        // if they are above and below, and there's some common x coords, make link vertical
        if (!$yOverlaps && $xOverlaps) {
            list($aXOffset, $bXOffset) = $this->tidyComplexDimension(
                $boundingBoxA,
                $boundingBoxB,
                $nodeA,
                $nodeB,
                0,
                "x",
                $linknumber,
                $linktotal
            );
            list($aYOffset, $bYOffset) = $this->tidySimpleDimension(
                $boundingBoxA,
                $boundingBoxB,
                $nodeA,
                $nodeB,
                1,
                "y"
            );
        }

        if (!$xOverlaps && !$yOverlaps) {
            // TODO - Do something clever here - nearest corners, or an angled-VIA link?
        }

        // unwritten/implied - if both overlap, you're doing something weird and you're on your own

        // make the simplest possible offset string and finally, update the offsets
        $link->endpoints[0]->offset = $this->simplifyOffset($aXOffset, $aYOffset);
        $link->endpoints[1]->offset = $this->simplifyOffset($bXOffset, $bYOffset);

        // and also add a note that this link was tidied, and is eligible for automatic retidying
        $link->addHint("_tidied", 1);
    }

    /**
     * rangeOverlaps - check if two ranges have anything in common. Used for tidy()
     *
     * @param float[] $rangeA
     * @param float[] $rangeB
     * @return bool
     */
    public static function rangeOverlaps($rangeA, $rangeB)
    {
        if ($rangeA[0] > $rangeB[1]) {
            return false;
        }
        if ($rangeB[0] > $rangeA[1]) {
            return false;
        }

        return true;
    }

    /**
     * @param Rectangle $boundingBoxA
     * @param Rectangle $boundingBoxB
     * @param MapNode $nodeA
     * @param MapNode $nodeB
     * @param $simpleIndex
     * @param string $simpleCoordinate
     * @return array
     */
    private function tidySimpleDimension($boundingBoxA, $boundingBoxB, $nodeA, $nodeB, $simpleIndex, $simpleCoordinate)
    {
        // snap the easy coord to the appropriate edge of the node
        // [A] [B]
        if ($boundingBoxA[$simpleIndex + 2] < $boundingBoxB[$simpleIndex]) {
            $aOffset = $boundingBoxA[$simpleIndex + 2] - $nodeA->$simpleCoordinate;
            $bOffset = $boundingBoxB[$simpleIndex] - $nodeB->$simpleCoordinate;
            return array($aOffset, $bOffset);
        }

        // [B] [A]
        if ($boundingBoxB[$simpleIndex + 2] < $boundingBoxA[$simpleIndex]) {
            $aOffset = $boundingBoxA[$simpleIndex] - $nodeA->$simpleCoordinate;
            $bOffset = $boundingBoxB[$simpleIndex + 2] - $nodeB->$simpleCoordinate;
            return array($aOffset, $bOffset);
        }
        return array(0, 0);
    }

    /**
     * @param $boundingBoxA
     * @param $boundingBoxB
     * @param $nodeA
     * @param $nodeB
     * @param $complexIndex
     * @param $complexCoordinate
     * @param $linkIndex
     * @param $linkCount
     * @return array
     */
    private function tidyComplexDimension(
        $boundingBoxA,
        $boundingBoxB,
        $nodeA,
        $nodeB,
        $complexIndex,
        $complexCoordinate,
        $linkIndex,
        $linkCount
    ) {
        // find the overlapping span for the 'hard' coordinate, then divide it into $linkTotal equal steps
        // this should be true whichever way around they are
        list($minimumOverlap, $maximumOverlap) = $this->findCommonRange(
            array(
                $boundingBoxA[$complexIndex],
                $boundingBoxA[$complexIndex + 2]
            ),
            array(
                $boundingBoxB[$complexIndex],
                $boundingBoxB[$complexIndex + 2]
            )
        );
        $overlap = $maximumOverlap - $minimumOverlap;
        $stepPerLink = $overlap / ($linkCount + 1);

        $aOffset = $minimumOverlap + ($linkIndex * $stepPerLink) - $nodeA->$complexCoordinate;
        $bOffset = $minimumOverlap + ($linkIndex * $stepPerLink) - $nodeB->$complexCoordinate;

        return array($aOffset, $bOffset);
    }

    /**
     * findCommonRange - find the range of numbers where two ranges overlap. Used for tidy()
     *
     * @param number[] $rangeA
     * @param number[] $rangeB
     * @return number[] list($min,$max)
     */
    public static function findCommonRange($rangeA, $rangeB)
    {
        $minimumOverlap = max($rangeA[0], $rangeB[0]);
        $maximumOverlap = min($rangeA[1], $rangeB[1]);

        return array($minimumOverlap, $maximumOverlap);
    }

    /**
     * Turn the offsets produced during Tidy into simpler ones, if possible.
     * (including ':0:0' into '')
     *
     * @param int $xOffset
     * @param int $yOffset
     * @return string
     */
    public static function simplifyOffset($xOffset, $yOffset)
    {
        if ($xOffset == 0 && $yOffset == 0) {
            return "";
        }

        if ($xOffset == 0) {
            if ($yOffset < 0) {
                return "N95";
            } else {
                return "S95";
            }
        }

        if ($yOffset == 0) {
            if ($xOffset < 0) {
                return "W95";
            } else {
                return "E95";
            }
        }

        return sprintf("%d:%d", $xOffset, $yOffset);
    }

    public function tidyAllLinks()
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // draw a map and throw it away, to calculate all the bounding boxes
        $this->map->drawMap('null');
        $this->doRetidyLinks(true);
    }

    /**
     * _retidy_links - find all links that have previously been tidied (_tidied hint) and tidy them again
     * UNLESS $ignore_tidied is set, then do every single link (for editor testing)
     *
     * @param boolean $ignoreTidied
     * @throws WeathermapInternalFail
     */
    private function doRetidyLinks($ignoreTidied = false)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // draw a map and throw it away, to calculate all the bounding boxes
        // $this->map->drawMap('null');

        $routes = array();

        // build a list of non-template links with their route - a simple key that we can use to tell if two
        // links go between the same nodes
        // not very efficient, but it saves looking for special cases (a->b & b->a together)
        foreach ($this->map->links as $link) {
            if (!$link->isTemplate()) {
                $route = $this->makeRouteKey($link);
                if (($ignoreTidied || $link->getHint("_tidied") == 1)) {
                    $routes[$route][] = $link;
                }
            }
        }

        foreach ($routes as $route => $linkList) {
            $this->tidyParallelLinks($linkList);
        }
    }

    /**
     * @param MapLink $link
     * @return string
     */
    private function makeRouteKey($link)
    {
        $route = $link->endpoints[0]->node->name . " " . $link->endpoints[1]->node->name;
        if (strcmp($link->endpoints[0]->node->name, $link->endpoints[1]->node->name) > 0) {
            $route = $link->endpoints[1]->node->name . " " . $link->endpoints[0]->node->name;
            return $route;
        }
        return $route;
    }

    /**
     * tidyParallelLinks - for a group of links between the same two nodes, distribute them
     * nicely.
     *
     * @param MapLink[] $links - the links to treat as a group
     *
     */
    public function tidyParallelLinks($links)
    {
        $nTargets = count($links);

        $i = 1;
        foreach ($links as $link) {
            $this->tidyOneLink($link, $i, $nTargets);
            $i++;
        }
    }

    public function retidyAllLinks()
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // draw a map and throw it away, to calculate all the bounding boxes
        $this->map->drawMap('null');
        $this->doRetidyLinks(true);
    }

    public function retidyLinks()
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        // draw a map and throw it away, to calculate all the bounding boxes
        $this->map->drawMap('null');
        $this->doRetidyLinks(false);
    }

    /**
     * untidyLinks - remove all link offsets from the map. Used mainly for testing.
     *
     */
    public function untidyLinks()
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        foreach ($this->map->links as $link) {
            $link->endpoints[0]->offset = "C";
            $link->endpoints[1]->offset = "C";
        }
    }

    public function placeLegend($x, $y, $scaleName = "DEFAULT")
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $this->map->legends[$scaleName]->setPosition(new Point($x, $y));
    }

    public function placeTitle($x, $y)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $this->map->titlex = $x;
        $this->map->titley = $y;
    }

    public function placeTimestamp($x, $y)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $this->map->timex = $x;
        $this->map->timey = $y;
    }

    public function updateMapStyle($params)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $this->map->htmlstyle = $params['htmlstyle'];
        $this->map->keyfont = intval($params['legendfont']);

        $inheritables = array(
            array('link', 'labelStyle', 'bwlabels', ""),
            array('link', 'bwfont', 'linkfont', "int"),
            array('link', 'arrowStyle', 'arrowstyle', ""),
            array('node', 'labelfont', 'nodefont', "int")
        );
        $this->handleInheritance($inheritables, $params);
    }


    public function updateMapProperties($params)
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        $this->map->title = $params['title'];
        $this->map->legends['DEFAULT']->keytitle = $params['legend'];
        $this->map->stamptext = $params['stamp'];

        $this->map->htmloutputfile = $params['htmlfile'];
        $this->map->imageoutputfile = $params['pngfile'];

        $this->map->width = $params['width'];
        $this->map->height = $params['height'];

        $this->map->background = $params['bgfile'];


        $inheritables = array(
            array('link', 'width', 'linkdefaultwidth', "float"),
        );

        $this->handleInheritance($inheritables, $params);

        $defaultLink = $this->map->getLink("DEFAULT");

        $defaultLink->width = $params['linkdefaultwidth'];
        $defaultLink->addNote("my_width", $params['linkdefaultwidth']);


        $bwIn = $params['linkdefaultbwin'];
        $bwOut = $params['linkdefaultbwout'];

        $bwInOld = $defaultLink->maxValuesConfigured[IN];
        $bwOutOld = $defaultLink->maxValuesConfigured[OUT];

        // TODO - there are two methods doing this job in UIBase
        if (!UIBase::wmeValidateBandwidth($bwOut)) {
            $bwOut = $bwOutOld;
        }

        if (!UIBase::wmeValidateBandwidth($bwIn)) {
            $bwIn = $bwInOld;
        }

        if (($bwInOld != $bwIn) || ($bwOutOld != $bwOut)) {
            $defaultLink->maxValuesConfigured[IN] = $bwIn;
            $defaultLink->maxValuesConfigured[OUT] = $bwOut;
            $defaultLink->maxValues[IN] = StringUtility::interpretNumberWithMetricSuffix($bwIn, $this->map->kilo);
            $defaultLink->maxValues[OUT] = StringUtility::interpretNumberWithMetricSuffix($bwOut, $this->map->kilo);
        }

        foreach ($this->map->links as $link) {
            if (($link->maxValuesConfigured[IN] == $bwInOld) || ($link->maxValuesConfigured[OUT] == $bwOutOld)) {
                $link->maxValuesConfigured[IN] = $bwIn;
                $link->maxValuesConfigured[OUT] = $bwOut;
                $link->maxValues[IN] = StringUtility::interpretNumberWithMetricSuffix($bwIn, $this->map->kilo);
                $link->maxValues[OUT] = StringUtility::interpretNumberWithMetricSuffix($bwOut, $this->map->kilo);
            }
        }
    }

    public function asJS()
    {
        if (!$this->isLoaded()) {
            throw new WeathermapInternalFail("Map must be loaded before editing API called.");
        }

        throw new WeathermapInternalFail("unimplemented");
    }

    /**
     * Find all the items where a parameter matches the CURRENT default node/link
     * and change those to match the NEW default node/link, so that the default
     * settings in the mapstyle page act intuitively.
     *
     * @param $inheritables
     * @param $params
     */
    private function handleInheritance($inheritables, $params)
    {
        $defaultLink = $this->map->getLink("DEFAULT");
        $defaultNode = $this->map->getNode("DEFAULT");

        foreach ($inheritables as $inheritable) {
            $propertyName = $inheritable[1];
            $parameterName = $inheritable[2];
            $validationType = $inheritable[3];

            $new = $params[$parameterName];
            if ($validationType != "") {
                switch ($validationType) {
                    case "int":
                        $new = intval($new);
                        break;
                    case "float":
                        $new = floatval($new);
                        break;
                }
            }

            $default = null;
            $itemList = array();

            if ($inheritable[0] == 'node') {
                $itemList = $this->map->nodes;
                $default = $defaultNode;
            }
            if ($inheritable[0] == 'link') {
                $itemList = $this->map->links;
                $default = $defaultLink;
            }
            $old = $default->$propertyName;

            if ($old != $new) {
                $default->$propertyName = $new;
                foreach ($itemList as $item) {
                    if ($item->name != ":: DEFAULT ::" && $item->$propertyName == $old) {
                        $item->$propertyName = $new;
                    }
                }
            }
        }
    }
}