Simon Hunt

GUI -- Further work on MapService and GeoDataService. Still WIP.

Change-Id: I92e826cc15cc1a07238cc4b4eac20583260a3c84
......@@ -21,18 +21,36 @@
*/
/*
The GeoData Service caches GeoJSON map data, and provides supporting
projections for mapping into SVG layers.
The GeoData Service facilitates the fetching and caching of TopoJSON data
from the server, as well as providing a way of creating a path generator
for that data, to be used to render the map in an SVG layer.
A GeoMap object can be fetched by ID. IDs that start with an asterisk
A TopoData object can be fetched by ID. IDs that start with an asterisk
identify maps bundled with the GUI. IDs that do not start with an
asterisk are assumed to be URLs to externally provided data (exact
format to be decided).
asterisk are assumed to be URLs to externally provided data.
e.g. var geomap = GeoDataService.fetchGeoMap('*continental-us');
var topodata = GeoDataService.fetchTopoData('*continental-us');
Note that, since the GeoMap instance is cached / shared, it should
contain no state.
The path generator can then be created for that data-set:
var gen = GeoDataService.createPathGenerator(topodata, opts);
opts is an optional argument that allows the override of default settings:
{
objectTag: 'states',
projection: d3.geo.mercator(),
logicalSize: 1000,
mapFillScale: .95
};
The returned object (gen) comprises transformed data (TopoJSON -> GeoJSON),
the D3 path generator function, and the settings used ...
{
geodata: { ... },
pathgen: function (...) { ... },
settings: { ... }
}
*/
(function () {
......@@ -66,9 +84,9 @@
// returns a promise decorated with:
// .meta -- id, url, and whether the data was cached
// .mapdata -- geojson data (on response from server)
// .topodata -- TopoJSON data (on response from server)
function fetchGeoMap(id) {
function fetchTopoData(id) {
if (!fs.isS(id)) {
return null;
}
......@@ -88,10 +106,10 @@
promise.then(function (response) {
// success
promise.mapdata = response.data;
promise.topodata = response.data;
}, function (response) {
// error
$log.warn('Failed to retrieve map data: ' + url,
$log.warn('Failed to retrieve map TopoJSON data: ' + url,
response.status, response.data);
});
......@@ -104,15 +122,32 @@
return promise;
}
// TODO: clean up implementation of projection...
function setProjForView(path, topoData) {
var dim = 1000;
var defaultGenSettings = {
objectTag: 'states',
projection: d3.geo.mercator(),
logicalSize: 1000,
mapFillScale: .95
};
// converts given TopoJSON-format data into corresponding GeoJSON
// data, and creates a path generator for that data.
function createPathGenerator(topoData, opts) {
var settings = $.extend({}, defaultGenSettings, opts),
topoObject = topoData.objects[settings.objectTag],
geoData = topojson.feature(topoData, topoObject),
proj = settings.projection,
dim = settings.logicalSize,
mfs = settings.mapFillScale,
path = d3.geo.path().projection(proj);
// adjust projection scale and translation to fill the view
// with the map
// start with unit scale, no translation..
geoMapProj.scale(1).translate([0, 0]);
proj.scale(1).translate([0, 0]);
// figure out dimensions of map data..
var b = path.bounds(topoData),
var b = path.bounds(geoData),
x1 = b[0][0],
y1 = b[0][1],
x2 = b[1][0],
......@@ -123,17 +158,24 @@
y = (y1 + y2) / 2;
// size map to 95% of minimum dimension to fill space..
var s = .95 / Math.min(dx / dim, dy / dim);
var t = [dim / 2 - s * x, dim / 2 - s * y];
var s = mfs / Math.min(dx / dim, dy / dim),
t = [dim / 2 - s * x, dim / 2 - s * y];
// set new scale, translation on the projection..
geoMapProj.scale(s).translate(t);
proj.scale(s).translate(t);
// return the results
return {
geodata: geoData,
pathgen: path,
settings: settings
};
}
return {
clearCache: clearCache,
fetchGeoMap: fetchGeoMap
fetchTopoData: fetchTopoData,
createPathGenerator: createPathGenerator
};
}]);
}());
\ No newline at end of file
......
......@@ -27,126 +27,40 @@
e.g. var ok = MapService.loadMapInto(svgLayer, '*continental-us');
The Map Service makes use of the GeoDataService to load the required data
from the server.
from the server and to create the appropriate geographical projection.
*/
(function () {
'use strict';
// injected references
var $log, $http, fs;
// internal state
var mapCache = d3.map(),
bundledUrlPrefix = '../data/map/';
function getUrl(id) {
if (id[0] === '*') {
return bundledUrlPrefix + id.slice(1) + '.json';
}
return id + '.json';
}
var $log, fs, gds;
angular.module('onosSvg')
.factory('MapService', ['$log', '$http', 'FnService',
function (_$log_, _$http_, _fs_) {
.factory('MapService', ['$log', 'FnService', 'GeoDataService',
function (_$log_, _fs_, _gds_) {
$log = _$log_;
$http = _$http_;
fs = _fs_;
function fetchGeoMap(id) {
if (!fs.isS(id)) {
return null;
}
var url = getUrl(id),
promise = mapCache.get(id);
if (!promise) {
// need to fetch the data, build the object,
// cache it, and return it.
promise = $http.get(url);
promise.meta = {
id: id,
url: url,
wasCached: false
};
promise.then(function (response) {
// success
promise.mapdata = response.data;
}, function (response) {
// error
$log.warn('Failed to retrieve map data: ' + url,
response.status, response.data);
});
mapCache.set(id, promise);
} else {
promise.meta.wasCached = true;
}
return promise;
}
var geoMapProj;
function setProjForView(path, topoData) {
var dim = 1000;
// start with unit scale, no translation..
geoMapProj.scale(1).translate([0, 0]);
// figure out dimensions of map data..
var b = path.bounds(topoData),
x1 = b[0][0],
y1 = b[0][1],
x2 = b[1][0],
y2 = b[1][1],
dx = x2 - x1,
dy = y2 - y1,
x = (x1 + x2) / 2,
y = (y1 + y2) / 2;
// size map to 95% of minimum dimension to fill space..
var s = .95 / Math.min(dx / dim, dy / dim);
var t = [dim / 2 - s * x, dim / 2 - s * y];
// set new scale, translation on the projection..
geoMapProj.scale(s).translate(t);
}
gds = _gds_;
function loadMapInto(mapLayer, id) {
var mapObject = fetchGeoMap(id);
if (!mapObject) {
var promise = gds.fetchTopoData(id);
if (!promise) {
$log.warn('Failed to load map: ' + id);
return null;
return false;
}
var mapdata = mapObject.mapdata,
topoData, path;
mapObject.then(function () {
// extracts the topojson data into geocoordinate-based geometry
topoData = topojson.feature(mapdata, mapdata.objects.states);
// see: http://bl.ocks.org/mbostock/4707858
geoMapProj = d3.geo.mercator();
path = d3.geo.path().projection(geoMapProj);
setProjForView(path, topoData);
promise.then(function () {
var gen = gds.createPathGenerator(promise.topodata);
mapLayer.selectAll('path')
.data(topoData.features)
.data(gen.geodata.features)
.enter()
.append('path')
.attr('d', path);
.attr('d', gen.pathgen);
});
// TODO: review whether we should just return true (not the map object)
return mapObject;
return true;
}
return {
......
......@@ -32,7 +32,7 @@
var $log, ks, zs, gs, ms;
// DOM elements
var svg, defs;
var svg, defs, zoomLayer, map;
// Internal state
var zoomer;
......@@ -91,7 +91,7 @@
}
function setUpZoom() {
var zoomLayer = svg.append('g').attr('id', 'topo-zoomlayer');
zoomLayer = svg.append('g').attr('id', 'topo-zoomlayer');
zoomer = zs.createZoomer({
svg: svg,
zoomLayer: zoomLayer,
......@@ -101,6 +101,13 @@
}
// --- Background Map ------------------------------------------------
function setUpMap() {
map = zoomLayer.append('g').attr('id', '#topo-map');
ms.loadMapInto(map, '*continental_us');
}
// --- Controller Definition -----------------------------------------
angular.module('ovTopo', moduleDependencies)
......@@ -124,6 +131,7 @@
setUpKeys();
setUpDefs();
setUpZoom();
setUpMap();
$log.log('OvTopoCtrl has been created');
}]);
......
......@@ -39,18 +39,18 @@ describe('factory: fw/svg/geodata.js', function() {
it('should define api functions', function () {
expect(fs.areFunctions(gds, [
'clearCache', 'fetchGeoMap'
'clearCache', 'fetchTopoData', 'createPathGenerator'
])).toBeTruthy();
});
it('should return null when no parameters given', function () {
promise = gds.fetchGeoMap();
promise = gds.fetchTopoData();
expect(promise).toBeNull();
});
it('should augment the id of a bundled map', function () {
var id = '*foo';
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise.meta).toBeDefined();
expect(promise.meta.id).toBe(id);
expect(promise.meta.url).toBe('../data/map/foo.json');
......@@ -58,7 +58,7 @@ describe('factory: fw/svg/geodata.js', function() {
it('should treat an external id as the url itself', function () {
var id = 'some/path/to/foo';
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise.meta).toBeDefined();
expect(promise.meta.id).toBe(id);
expect(promise.meta.url).toBe(id + '.json');
......@@ -66,14 +66,14 @@ describe('factory: fw/svg/geodata.js', function() {
it('should cache the returned objects', function () {
var id = 'foo';
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise).toBeDefined();
expect(promise.meta.wasCached).toBeFalsy();
expect(promise.tagged).toBeUndefined();
promise.tagged = 'I woz here';
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise).toBeDefined();
expect(promise.meta.wasCached).toBeTruthy();
expect(promise.tagged).toEqual('I woz here');
......@@ -81,14 +81,14 @@ describe('factory: fw/svg/geodata.js', function() {
it('should clear the cache when asked', function () {
var id = 'foo';
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise.meta.wasCached).toBeFalsy();
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise.meta.wasCached).toBeTruthy();
gds.clearCache();
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
expect(promise.meta.wasCached).toBeFalsy();
});
......@@ -98,12 +98,64 @@ describe('factory: fw/svg/geodata.js', function() {
$httpBackend.expectGET('foo.json').respond(404, 'Not found');
spyOn($log, 'warn');
promise = gds.fetchGeoMap(id);
promise = gds.fetchTopoData(id);
$httpBackend.flush();
expect(promise.mapdata).toBeUndefined();
expect(promise.topodata).toBeUndefined();
expect($log.warn)
.toHaveBeenCalledWith('Failed to retrieve map data: foo.json',
.toHaveBeenCalledWith('Failed to retrieve map TopoJSON data: foo.json',
404, 'Not found');
});
// --- path generator tests
function simpleTopology(object) {
return {
type: "Topology",
transform: {scale: [1, 1], translate: [0, 0]},
objects: {states: object},
arcs: [
[[0, 0], [1, 0], [0, 1], [-1, 0], [0, -1]],
[[0, 0], [1, 0], [0, 1]],
[[1, 1], [-1, 0], [0, -1]],
[[1, 1]],
[[0, 0]]
]
};
}
function simpleLineStringTopo() {
return simpleTopology({type: "LineString", arcs: [1, 2]});
}
it('should use default settings if none are supplied', function () {
var gen = gds.createPathGenerator(simpleLineStringTopo());
expect(gen.settings.objectTag).toBe('states');
expect(gen.settings.logicalSize).toBe(1000);
expect(gen.settings.mapFillScale).toBe(.95);
// best we can do for now is test that projection is a function ...
expect(fs.isF(gen.settings.projection)).toBeTruthy();
});
it('should allow us to override default settings', function () {
var gen = gds.createPathGenerator(simpleLineStringTopo(), {
mapFillScale: .80
});
expect(gen.settings.objectTag).toBe('states');
expect(gen.settings.logicalSize).toBe(1000);
expect(gen.settings.mapFillScale).toBe(.80);
});
it('should create transformed geodata, and a path generator', function () {
var gen = gds.createPathGenerator(simpleLineStringTopo());
expect(fs.isO(gen.settings)).toBeTruthy();
expect(fs.isO(gen.geodata)).toBeTruthy();
expect(fs.isF(gen.pathgen)).toBeTruthy();
});
// NOTE: we probably should have more unit tests that assert stuff about
// the transformed data (geo data) -- though perhaps we can rely on
// the unit testing of TopoJSON? See...
// https://github.com/mbostock/topojson/blob/master/test/feature-test.js
// and, what about the path generator?, and the computed bounds?
// In summary, more work should be done here..
});
......