Layout and Region configs.
- Listen for topo-layout config changes.
- Augmenting UiTopoLayout to include fields for geomap/sprite, scale/offset

Change-Id: I2b1f747f41d39b64b0a1a53946c4cbd5750db9e5
diff --git a/cli/src/main/java/org/onosproject/cli/net/LayoutAddCommand.java b/cli/src/main/java/org/onosproject/cli/net/LayoutAddCommand.java
index 5cd5c3b..8665a4e 100644
--- a/cli/src/main/java/org/onosproject/cli/net/LayoutAddCommand.java
+++ b/cli/src/main/java/org/onosproject/cli/net/LayoutAddCommand.java
@@ -59,7 +59,7 @@
         Region region = regionId == null ? null : regionService.getRegion(regionId(regionId));
         UiTopoLayoutId pid = parentId == null ? UiTopoLayoutId.DEFAULT_ID : layoutId(parentId);
 
-        UiTopoLayout layout = new UiTopoLayout(layoutId(id), region, pid);
+        UiTopoLayout layout = new UiTopoLayout(layoutId(id)).region(region).parent(pid);
         service.addLayout(layout);
     }
 }
diff --git a/core/api/src/main/java/org/onosproject/net/config/basics/BasicUiTopoLayoutConfig.java b/core/api/src/main/java/org/onosproject/net/config/basics/BasicUiTopoLayoutConfig.java
index e75df69..4091f4e 100644
--- a/core/api/src/main/java/org/onosproject/net/config/basics/BasicUiTopoLayoutConfig.java
+++ b/core/api/src/main/java/org/onosproject/net/config/basics/BasicUiTopoLayoutConfig.java
@@ -132,12 +132,12 @@
     /**
      * Sets the name of the geomap (topojson file) to use for this layout.
      *
-     * @param geomap geomap name
+     * @param geomap geomap name; null to clear
      * @return config for UI topology layout
      * @throws InvalidFieldException if the sprites field is already set
      */
     public BasicUiTopoLayoutConfig geomap(String geomap) {
-        if (hasField(SPRITES)) {
+        if (geomap != null && hasField(SPRITES)) {
             throw new InvalidFieldException(GEOMAP, E_SPRITES_ALREADY_SET);
         }
         setOrClear(GEOMAP, geomap);
@@ -156,12 +156,12 @@
     /**
      * Sets the name of the sprites definition to use for this layout.
      *
-     * @param sprites sprites definition name
+     * @param sprites sprites definition name; null to clear
      * @return config for UI topology layout
      * @throws InvalidFieldException if the geomap field is already set
      */
     public BasicUiTopoLayoutConfig sprites(String sprites) {
-        if (hasField(GEOMAP)) {
+        if (sprites != null && hasField(GEOMAP)) {
             throw new InvalidFieldException(GEOMAP, E_GEOMAP_ALREADY_SET);
         }
         setOrClear(SPRITES, sprites);
diff --git a/core/api/src/main/java/org/onosproject/ui/UiTopoLayoutService.java b/core/api/src/main/java/org/onosproject/ui/UiTopoLayoutService.java
index 3a42dac..8872087 100644
--- a/core/api/src/main/java/org/onosproject/ui/UiTopoLayoutService.java
+++ b/core/api/src/main/java/org/onosproject/ui/UiTopoLayoutService.java
@@ -37,7 +37,7 @@
     UiTopoLayout getRootLayout();
 
     /**
-     * Returns the set of available layouts.
+     * Returns the set of available layouts (not including the root layout).
      *
      * @return set of available layouts
      */
diff --git a/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopoLayout.java b/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopoLayout.java
index ade86e1..b37f1df 100644
--- a/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopoLayout.java
+++ b/core/api/src/main/java/org/onosproject/ui/model/topo/UiTopoLayout.java
@@ -16,36 +16,68 @@
 
 package org.onosproject.ui.model.topo;
 
+import com.google.common.base.MoreObjects;
 import org.onosproject.net.region.Region;
 import org.onosproject.net.region.RegionId;
 
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
 /**
  * Represents a specific "subset" of the UI model of the network topology
  * that a user might wish to view. Backed by a {@link Region}.
+ * <p>
+ * These instances include information about which geo-map (or sprite definition)
+ * should be displayed, along with zoom and offset parameters.
  */
 public class UiTopoLayout {
 
+    // package private for unit test access
+    static final double SCALE_MIN = 0.01;
+    static final double SCALE_MAX = 100.0;
+    static final double SCALE_DEFAULT = 1.0;
+    static final double OFFSET_DEFAULT = 0.0;
+
+    static final String E_ROOT_PARENT = "Cannot change parent ID of root layout";
+    static final String E_ROOT_REGION = "Cannot set region on root layout";
+    static final String E_SPRITES_SET = "Cannot set geomap if sprites is set";
+    static final String E_GEOMAP_SET = "Cannot set sprites if geomap is set";
+    static final String E_SCALE_OOB =
+            "Scale out of bounds; expected [" + SCALE_MIN + ".." + SCALE_MAX + "]";
+
     private final UiTopoLayoutId id;
-    private final Region region;
-    private final UiTopoLayoutId parent;
+
+    private Region region;
+    private UiTopoLayoutId parent;
+    private String geomap;
+    private String sprites;
+    private double scale = SCALE_DEFAULT;
+    private double offsetX = OFFSET_DEFAULT;
+    private double offsetY = OFFSET_DEFAULT;
 
     /**
      * Created a new UI topology layout.
      *
-     * @param id     layout identifier
-     * @param region backing region
-     * @param parent identifier of the parent layout
+     * @param id layout identifier
      */
-    public UiTopoLayout(UiTopoLayoutId id, Region region, UiTopoLayoutId parent) {
+    public UiTopoLayout(UiTopoLayoutId id) {
+        checkNotNull(id, "layout ID cannot be null");
         this.id = id;
-        this.region = region;
+
         // NOTE: root layout is its own parent...
-        this.parent = parent != null ? parent : this.id;
+        if (isRoot()) {
+            parent = id;
+        }
     }
 
-    @Override
-    public String toString() {
-        return "{UiTopoLayout: " + id + "}";
+    /**
+     * Returns true if this layout instance is at the top of the
+     * hierarchy tree.
+     *
+     * @return true if this is the root layout
+     */
+    public boolean isRoot() {
+        return UiTopoLayoutId.DEFAULT_ID.equals(id);
     }
 
     /**
@@ -57,6 +89,37 @@
         return id;
     }
 
+    @Override
+    public String toString() {
+        return MoreObjects.toStringHelper(this)
+                .add("id", id)
+                .add("region", region)
+                .add("parent", parent)
+                .add("geomap", geomap)
+                .add("sprites", sprites)
+                .add("scale", scale)
+                .add("offsetX", offsetX)
+                .add("offsetY", offsetY)
+                .toString();
+    }
+
+    /**
+     * Sets the backing region for this layout. Note that an exception will
+     * be thrown if this is the root layout.
+     *
+     * @param region the backing region
+     * @return self, for chaining
+     * @throws IllegalArgumentException if this is the root layout
+     */
+    public UiTopoLayout region(Region region) {
+        if (isRoot()) {
+            throw new IllegalArgumentException(E_ROOT_REGION);
+        }
+
+        this.region = region;
+        return this;
+    }
+
     /**
      * Returns the backing region with which this layout is associated. Note
      * that this may be null (for the root layout).
@@ -76,8 +139,27 @@
      * @return backing region identifier
      */
     public RegionId regionId() {
-        return isRoot() ? UiRegion.NULL_ID
-                : (region == null ? null : region.id());
+        return isRoot() ? UiRegion.NULL_ID :
+                (region == null ? null : region.id());
+    }
+
+    /**
+     * Sets the identity of this layout's parent. May be null to unset.
+     * Note that an exception will be thrown if this is the root layout,
+     * since the parent of the root is always itself, and cannot be changed.
+     *
+     * @param parentId parent layout identifier
+     * @return self, for chaining
+     * @throws IllegalArgumentException if this instance is the root layout
+     */
+    public UiTopoLayout parent(UiTopoLayoutId parentId) {
+        if (isRoot()) {
+            throw new IllegalArgumentException(E_ROOT_PARENT);
+        }
+        // TODO: consider checking ancestry chain to prevent loops
+
+        parent = parentId;
+        return this;
     }
 
     /**
@@ -90,12 +172,129 @@
     }
 
     /**
-     * Returns true if this layout instance is at the top of the
-     * hierarchy tree.
+     * Sets the name of the geomap for this layout. This is the symbolic
+     * name for a "topojson" file containing a geographic map projection,
+     * to be displayed in the topology view, for this layout.
+     * <p>
+     * Since the geomap and sprites fields are mutually exclusive, this
+     * method will throw an exception if the sprites field is already set.
      *
-     * @return true if this is the root layout
+     * @param geomap the geomap name
+     * @return self, for chaining
+     * @throws IllegalArgumentException if the sprites field is not null
      */
-    public boolean isRoot() {
-        return id.equals(parent);
+    public UiTopoLayout geomap(String geomap) {
+        if (sprites != null) {
+            throw new IllegalArgumentException(E_SPRITES_SET);
+        }
+        this.geomap = geomap;
+        return this;
     }
+
+    /**
+     * Returns the symbolic name for the geomap for this layout.
+     *
+     * @return name of geomap
+     */
+    public String geomap() {
+        return geomap;
+    }
+
+    /**
+     * Sets the name of the sprites definition for this layout. This is the
+     * symbolic name for a "json" file containing a definition of sprites,
+     * which render as a symbolic background (e.g. a campus, or floor plan),
+     * to be displayed in the topology view, for this layout.
+     * <p>
+     * Since the geomap and sprites fields are mutually exclusive, this
+     * method will throw an exception if the geomap field is already set.
+     *
+     * @param sprites the sprites definition name
+     * @return self, for chaining
+     * @throws IllegalArgumentException if the geomap field is not null
+     */
+    public UiTopoLayout sprites(String sprites) {
+        if (geomap != null) {
+            throw new IllegalArgumentException(E_GEOMAP_SET);
+        }
+        this.sprites = sprites;
+        return this;
+    }
+
+    /**
+     * Returns the symbolic name for the sprites definition for this layout.
+     *
+     * @return name of sprites definition
+     */
+    public String sprites() {
+        return sprites;
+    }
+
+    private boolean scaleWithinBounds(double scale) {
+        return scale >= SCALE_MIN && scale <= SCALE_MAX;
+    }
+
+    /**
+     * Sets the scale for the geomap / sprite image. Note that the
+     * acceptable bounds are from {@value #SCALE_MIN} to {@value #SCALE_MAX}.
+     *
+     * @param scale the scale
+     * @return self for chaining
+     * @throws IllegalArgumentException if the value is out of bounds
+     */
+    public UiTopoLayout scale(double scale) {
+        checkArgument(scaleWithinBounds(scale), E_SCALE_OOB);
+        this.scale = scale;
+        return this;
+    }
+
+    /**
+     * Returns the scale for the geomap / sprite image.
+     *
+     * @return the scale
+     */
+    public double scale() {
+        return scale;
+    }
+
+    /**
+     * Sets the x-offset value.
+     *
+     * @param offsetX x-offset
+     * @return self, for chaining
+     */
+    public UiTopoLayout offsetX(double offsetX) {
+        this.offsetX = offsetX;
+        return this;
+    }
+
+    /**
+     * Returns the x-offset value.
+     *
+     * @return the x-offset
+     */
+    public double offsetX() {
+        return offsetX;
+    }
+
+    /**
+     * Sets the y-offset value.
+     *
+     * @param offsetY y-offset
+     * @return self, for chaining
+     */
+    public UiTopoLayout offsetY(double offsetY) {
+        this.offsetY = offsetY;
+        return this;
+    }
+
+    /**
+     * Returns the y-offset value.
+     *
+     * @return the y-offset
+     */
+    public double offsetY() {
+        return offsetY;
+    }
+
 }
diff --git a/core/api/src/test/java/org/onosproject/ui/model/topo/UiTopoLayoutTest.java b/core/api/src/test/java/org/onosproject/ui/model/topo/UiTopoLayoutTest.java
new file mode 100644
index 0000000..b8db969
--- /dev/null
+++ b/core/api/src/test/java/org/onosproject/ui/model/topo/UiTopoLayoutTest.java
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2016-present Open Networking Laboratory
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.onosproject.ui.model.topo;
+
+import org.junit.Test;
+import org.onosproject.net.region.DefaultRegion;
+import org.onosproject.net.region.Region;
+import org.onosproject.net.region.RegionId;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.onosproject.net.region.Region.Type.CAMPUS;
+import static org.onosproject.net.region.RegionId.regionId;
+import static org.onosproject.ui.model.topo.UiTopoLayout.E_GEOMAP_SET;
+import static org.onosproject.ui.model.topo.UiTopoLayout.E_ROOT_PARENT;
+import static org.onosproject.ui.model.topo.UiTopoLayout.E_ROOT_REGION;
+import static org.onosproject.ui.model.topo.UiTopoLayout.E_SPRITES_SET;
+import static org.onosproject.ui.model.topo.UiTopoLayoutId.layoutId;
+
+/**
+ * Unit tests for {@link UiTopoLayout}.
+ */
+public class UiTopoLayoutTest {
+
+    private static final String AM_NOEX = "no exception thrown";
+    private static final String AM_WREXMSG = "wrong exception message";
+
+    private static final double DELTA = Double.MIN_VALUE * 2.0;
+
+    private static final UiTopoLayoutId OTHER_ID = layoutId("other-id");
+    private static final RegionId REGION_ID = regionId("some-region");
+    private static final Region REGION =
+            new DefaultRegion(REGION_ID, "Region-1", CAMPUS, null);
+    private static final String GEOMAP = "geo1";
+    private static final String SPRITE = "spr1";
+
+
+    private UiTopoLayout layout;
+
+    private void mkRootLayout() {
+        layout = new UiTopoLayout(UiTopoLayoutId.DEFAULT_ID);
+    }
+
+    private void mkOtherLayout() {
+        layout = new UiTopoLayout(OTHER_ID);
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void nullIdentifier() {
+        layout = new UiTopoLayout(null);
+    }
+
+    @Test
+    public void rootLayout() {
+        mkRootLayout();
+        assertEquals("wrong id", UiTopoLayoutId.DEFAULT_ID, layout.id());
+        assertEquals("wrong parent (not self)",
+                UiTopoLayoutId.DEFAULT_ID, layout.parent());
+        assertTrue("should be root", layout.isRoot());
+
+        assertNull("unexpected region", layout.region());
+        assertEquals("unexpected region id", UiRegion.NULL_ID, layout.regionId());
+    }
+
+    @Test
+    public void otherLayout() {
+        mkOtherLayout();
+        assertEquals("wrong id", OTHER_ID, layout.id());
+        assertEquals("not null parent", null, layout.parent());
+        assertFalse("should NOT be root", layout.isRoot());
+
+        // check attribute default values...
+        assertNull("unexpected region", layout.region());
+        assertNull("unexpected region id", layout.regionId());
+        assertNull("unexpected geomap", layout.geomap());
+        assertNull("unexpected sprites", layout.sprites());
+        assertEquals("non-unity scale", 1.0, layout.scale(), DELTA);
+        assertEquals("non-zero x-off", 0.0, layout.offsetX(), DELTA);
+        assertEquals("non-zero y-off", 0.0, layout.offsetY(), DELTA);
+    }
+
+    @Test
+    public void setRegionOnRoot() {
+        mkRootLayout();
+        try {
+            layout.region(REGION);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_ROOT_REGION, e.getMessage());
+        }
+
+        try {
+            layout.region(null);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_ROOT_REGION, e.getMessage());
+        }
+    }
+
+    @Test
+    public void setRegionOnOther() {
+        mkOtherLayout();
+        layout.region(REGION);
+        assertEquals("wrong region", REGION, layout.region());
+        assertEquals("wrong region id", REGION_ID, layout.regionId());
+
+        layout.region(null);
+        assertEquals("non-null region", null, layout.region());
+        assertEquals("non-null region id", null, layout.regionId());
+    }
+
+    @Test
+    public void setParentOnRoot() {
+        mkRootLayout();
+        try {
+            layout.parent(OTHER_ID);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_ROOT_PARENT, e.getMessage());
+        }
+
+        try {
+            layout.parent(null);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_ROOT_PARENT, e.getMessage());
+        }
+    }
+
+    @Test
+    public void setParentOnOther() {
+        mkOtherLayout();
+        layout.parent(OTHER_ID);
+        assertEquals("wrong parent", OTHER_ID, layout.parent());
+
+        layout.parent(null);
+        assertEquals("non-null parent", null, layout.parent());
+    }
+
+    @Test
+    public void setGeomap() {
+        mkRootLayout();
+        assertEquals("geo to start", null, layout.geomap());
+        layout.geomap(GEOMAP);
+        assertEquals("wrong geo", GEOMAP, layout.geomap());
+    }
+
+    @Test
+    public void setGeomapAfterSprites() {
+        mkRootLayout();
+        layout.sprites(SPRITE);
+        assertEquals("geo to start", null, layout.geomap());
+        try {
+            layout.geomap(GEOMAP);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_SPRITES_SET, e.getMessage());
+        }
+    }
+
+    @Test
+    public void setSprites() {
+        mkRootLayout();
+        assertEquals("sprite to start", null, layout.sprites());
+        layout.sprites(SPRITE);
+        assertEquals("wrong sprite", SPRITE, layout.sprites());
+    }
+
+    @Test
+    public void setSpritesAfterGeomap() {
+        mkRootLayout();
+        layout.geomap(GEOMAP);
+        assertEquals("sprites to start", null, layout.sprites());
+        try {
+            layout.sprites(SPRITE);
+            fail(AM_NOEX);
+        } catch (IllegalArgumentException e) {
+            assertEquals(AM_WREXMSG, E_GEOMAP_SET, e.getMessage());
+        }
+    }
+
+    @Test
+    public void setScale() {
+        mkRootLayout();
+        layout.scale(3.0);
+        assertEquals("wrong scale", 3.0, layout.scale(), DELTA);
+        layout.scale(0.05);
+        assertEquals("wrong scale", 0.05, layout.scale(), DELTA);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void scaleTooSmall() {
+        mkRootLayout();
+        layout.scale(0.0099);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void scaleTooBig() {
+        mkRootLayout();
+        layout.scale(100.009);
+    }
+
+    @Test
+    public void setXOff() {
+        mkOtherLayout();
+        layout.offsetX(23.4);
+        assertEquals("wrong x-offset", 23.4, layout.offsetX(), DELTA);
+    }
+
+    @Test
+    public void setYOff() {
+        mkOtherLayout();
+        layout.offsetY(2.71828);
+        assertEquals("wrong y-offset", 2.71828, layout.offsetY(), DELTA);
+    }
+
+}
diff --git a/tools/test/topos/uk-region.json b/tools/test/topos/uk-region.json
index a498bc9..7b42fa7 100644
--- a/tools/test/topos/uk-region.json
+++ b/tools/test/topos/uk-region.json
@@ -94,5 +94,4 @@
       }
     }
   }
-
 }
diff --git a/web/gui/src/main/java/org/onosproject/ui/impl/topo/UiTopoLayoutManager.java b/web/gui/src/main/java/org/onosproject/ui/impl/topo/UiTopoLayoutManager.java
index 5e6927f..193191c 100644
--- a/web/gui/src/main/java/org/onosproject/ui/impl/topo/UiTopoLayoutManager.java
+++ b/web/gui/src/main/java/org/onosproject/ui/impl/topo/UiTopoLayoutManager.java
@@ -23,12 +23,11 @@
 import org.apache.felix.scr.annotations.Reference;
 import org.apache.felix.scr.annotations.ReferenceCardinality;
 import org.apache.felix.scr.annotations.Service;
-import org.onlab.util.KryoNamespace;
+import org.onosproject.net.config.NetworkConfigEvent;
+import org.onosproject.net.config.NetworkConfigListener;
+import org.onosproject.net.config.NetworkConfigRegistry;
+import org.onosproject.net.config.basics.BasicUiTopoLayoutConfig;
 import org.onosproject.net.region.RegionId;
-import org.onosproject.store.serializers.KryoNamespaces;
-import org.onosproject.store.service.ConsistentMap;
-import org.onosproject.store.service.Serializer;
-import org.onosproject.store.service.StorageService;
 import org.onosproject.ui.UiTopoLayoutService;
 import org.onosproject.ui.model.topo.UiRegion;
 import org.onosproject.ui.model.topo.UiTopoLayout;
@@ -37,6 +36,7 @@
 import org.slf4j.LoggerFactory;
 
 import java.util.Collections;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
@@ -54,38 +54,34 @@
 @Service
 public class UiTopoLayoutManager implements UiTopoLayoutService {
 
-    private final Logger log = LoggerFactory.getLogger(getClass());
-
     private static final String ID_NULL = "Layout ID cannot be null";
     private static final String LAYOUT_NULL = "Layout cannot be null";
 
-    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
-    protected StorageService storageService;
+    private final Logger log = LoggerFactory.getLogger(getClass());
 
-    private ConsistentMap<UiTopoLayoutId, UiTopoLayout> layouts;
-    private Map<UiTopoLayoutId, UiTopoLayout> layoutMap;
+    private final InternalConfigListener cfgListener = new InternalConfigListener();
+    private final Map<UiTopoLayoutId, UiTopoLayout> layoutMap = new HashMap<>();
+
+    @Reference(cardinality = ReferenceCardinality.MANDATORY_UNARY)
+    protected NetworkConfigRegistry cfgService;
+
 
     @Activate
     public void activate() {
-        KryoNamespace.Builder kryoBuilder = new KryoNamespace.Builder()
-                .register(KryoNamespaces.API)
-                .register(UiTopoLayout.class);
-
-        layouts = storageService.<UiTopoLayoutId, UiTopoLayout>consistentMapBuilder()
-                .withSerializer(Serializer.using(kryoBuilder.build()))
-                .withName("onos-topo-layouts")
-                .withRelaxedReadConsistency()
-                .build();
-        layoutMap = layouts.asJavaMap();
 
         // Create and add the default layout, if needed.
-        layoutMap.computeIfAbsent(DEFAULT_ID, k -> new UiTopoLayout(k, null, null));
+        layoutMap.computeIfAbsent(DEFAULT_ID, UiTopoLayout::new);
+
+        cfgService.addListener(cfgListener);
+        cfgListener.initAllConfigs();
 
         log.info("Started");
     }
 
     @Deactivate
     public void deactivate() {
+        cfgService.removeListener(cfgListener);
+
         log.info("Stopped");
     }
 
@@ -103,7 +99,7 @@
     @Override
     public boolean addLayout(UiTopoLayout layout) {
         checkNotNull(layout, LAYOUT_NULL);
-        return layouts.put(layout.id(), layout) == null;
+        return layoutMap.put(layout.id(), layout) == null;
     }
 
     @Override
@@ -153,7 +149,51 @@
     @Override
     public boolean removeLayout(UiTopoLayout layout) {
         checkNotNull(layout, LAYOUT_NULL);
-        return layouts.remove(layout.id()) != null;
+        return layoutMap.remove(layout.id()) != null;
     }
 
+    /*
+     * Listens for changes to layout configs, updating instances as necessary
+     */
+    private class InternalConfigListener implements NetworkConfigListener {
+
+        // look up the current config by layout ID and apply it
+        private void updateLayoutConfig(UiTopoLayoutId id) {
+            BasicUiTopoLayoutConfig cfg =
+                    cfgService.getConfig(id, BasicUiTopoLayoutConfig.class);
+
+            log.info("Updating Layout via config... {}: {}", id, cfg);
+
+            UiTopoLayout layout = layoutMap.get(id);
+
+            // NOTE: if a value is null, then that null-ness should be set
+            // TODO: add setters on UiTopoLayout and implement...
+//            layout
+//              .region(cfg.region())
+//              .parent(cfg.parent())
+//              .geomap(cfg.geomap())
+//              .sprites(cfg.sprites())
+//              .scale(cfg.scale())
+//              .offsetX(cfg.offsetX())
+//              .offsetY(cfg.offsetY());
+        }
+
+        private void initAllConfigs() {
+            log.info("Initializing layout configurations...");
+            layoutMap.keySet().forEach(this::updateLayoutConfig);
+        }
+
+        @Override
+        public void event(NetworkConfigEvent event) {
+            UiTopoLayoutId id = (UiTopoLayoutId) event.subject();
+            updateLayoutConfig(id);
+        }
+
+        @Override
+        public boolean isRelevant(NetworkConfigEvent event) {
+            return (event.type() == NetworkConfigEvent.Type.CONFIG_ADDED ||
+                    event.type() == NetworkConfigEvent.Type.CONFIG_UPDATED) &&
+                    event.configClass().equals(BasicUiTopoLayoutConfig.class);
+        }
+    }
 }
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/UiTopoLayoutManagerTest.java b/web/gui/src/test/java/org/onosproject/ui/impl/topo/UiTopoLayoutManagerTest.java
index 10ceb8a..67fbf69 100644
--- a/web/gui/src/test/java/org/onosproject/ui/impl/topo/UiTopoLayoutManagerTest.java
+++ b/web/gui/src/test/java/org/onosproject/ui/impl/topo/UiTopoLayoutManagerTest.java
@@ -19,45 +19,62 @@
 import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
+import org.onosproject.net.config.NetworkConfigRegistryAdapter;
 import org.onosproject.net.region.DefaultRegion;
 import org.onosproject.net.region.Region;
-import org.onosproject.net.region.RegionId;
-import org.onosproject.store.service.TestStorageService;
 import org.onosproject.ui.UiTopoLayoutService;
 import org.onosproject.ui.model.topo.UiTopoLayout;
 import org.onosproject.ui.model.topo.UiTopoLayoutId;
 
-import static org.junit.Assert.*;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.onosproject.net.region.Region.Type.CAMPUS;
+import static org.onosproject.net.region.RegionId.regionId;
+import static org.onosproject.ui.model.topo.UiTopoLayoutId.layoutId;
 
 /**
  * Suite of unit tests for the UI topology layout manager.
  */
 public class UiTopoLayoutManagerTest {
 
+    private static class MockConfigService extends NetworkConfigRegistryAdapter {
+    }
+
+    private static Region region(String id, String name, Region.Type type) {
+        return new DefaultRegion(regionId(id), name, type, null);
+    }
+
+    private static UiTopoLayout layout(String id, Region region, String parentId) {
+        UiTopoLayoutId parent = parentId == null ? null : layoutId(parentId);
+        UiTopoLayout layout = new UiTopoLayout(layoutId(id));
+        // TODO: set region and parent
+        return layout;
+    }
+
+    private static final Region R1 = region("r1", "R1", CAMPUS);
+    private static final Region R2 = region("r2", "R2", CAMPUS);
+
+    private static final UiTopoLayout L1 = layout("l1", R1, null);
+    private static final UiTopoLayout L2 = layout("l2", R2, null);
+
+
     private UiTopoLayoutService svc;
     private UiTopoLayoutManager mgr;
 
-    private static final UiTopoLayout L1 =
-            new UiTopoLayout(UiTopoLayoutId.layoutId("l1"),
-                             new DefaultRegion(RegionId.regionId("r1"), "R1",
-                                               Region.Type.CAMPUS, null), null);
-    private static final UiTopoLayout L2 =
-            new UiTopoLayout(UiTopoLayoutId.layoutId("l2"),
-                             new DefaultRegion(RegionId.regionId("r2"), "R2",
-                                               Region.Type.CAMPUS, null), null);
 
     @Before
     public void setUp() {
         mgr = new UiTopoLayoutManager();
         svc = mgr;
-        mgr.storageService = new TestStorageService();
+
+        mgr.cfgService = new MockConfigService();
         mgr.activate();
     }
 
     @After
     public void tearDown() {
         mgr.deactivate();
-        mgr.storageService = null;
+        mgr.cfgService = null;
     }
 
     @Test
diff --git a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java
index ea48bb1..559a6be 100644
--- a/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java
+++ b/web/gui/src/test/java/org/onosproject/ui/impl/topo/model/AbstractTopoModelTest.java
@@ -263,7 +263,7 @@
                                          String parentId) {
         UiTopoLayoutId pid = parentId == null
                 ? UiTopoLayoutId.DEFAULT_ID : layoutId(parentId);
-        return new UiTopoLayout(layoutId(layoutId), region, pid);
+        return new UiTopoLayout(layoutId(layoutId)).region(region).parent(pid);
     }
 
     /**