diff --git a/src/objectliterals.jsdoc b/src/objectliterals.jsdoc
index 87a96162e5..d43e07eb6a 100644
--- a/src/objectliterals.jsdoc
+++ b/src/objectliterals.jsdoc
@@ -531,6 +531,16 @@
* @todo stability experimental
*/
+/**
+ * @typedef {Object} ol.source.VectorOptions
+ * @property {Array.
|undefined} attributions Attributions.
+ * @property {ol.Extent|undefined} extent Extent.
+ * @property {Array.|undefined} features Features.
+ * @property {string|undefined} logo Logo.
+ * @property {ol.proj.ProjectionLike} projection Projection.
+ * @property {ol.source.State|undefined} state State.
+ */
+
/**
* @typedef {Object} ol.source.WMTSOptions
* @property {Array.|undefined} attributions Attributions.
diff --git a/src/ol/source/vectorsource.js b/src/ol/source/vectorsource.js
new file mode 100644
index 0000000000..4f9162d508
--- /dev/null
+++ b/src/ol/source/vectorsource.js
@@ -0,0 +1,102 @@
+// FIXME put features in an ol.Collection
+// FIXME make change-detection more refined (notably, geometry hint)
+// FIXME keep R-Tree up-to-date, probably needs a new R-Tree implementation
+
+goog.provide('ol.source.Vector');
+
+goog.require('goog.asserts');
+goog.require('goog.events');
+goog.require('goog.events.Event');
+goog.require('goog.events.EventType');
+goog.require('ol.source.Source');
+goog.require('ol.structs.RTree');
+
+
+
+/**
+ * @constructor
+ * @extends {ol.source.Source}
+ * @param {ol.source.VectorOptions=} opt_options Options.
+ */
+ol.source.Vector = function(opt_options) {
+
+ var options = goog.isDef(opt_options) ? opt_options : {};
+
+ goog.base(this, {
+ attributions: options.attributions,
+ extent: options.extent,
+ logo: options.logo,
+ projection: options.projection,
+ state: options.state
+ });
+
+ /**
+ * @private
+ * @type {ol.structs.RTree}
+ */
+ this.rTree_ = new ol.structs.RTree();
+
+ /**
+ * @private
+ * @type {Object.}
+ */
+ this.featureChangeKeys_ = {};
+
+ if (goog.isDef(options.features)) {
+ var features = options.features;
+ var i, ii;
+ for (i = 0, ii = features.length; i < ii; ++i) {
+ this.addFeature(features[i]);
+ }
+ }
+
+};
+goog.inherits(ol.source.Vector, ol.source.Source);
+
+
+/**
+ * @param {ol.Feature} feature Feature.
+ */
+ol.source.Vector.prototype.addFeature = function(feature) {
+ var featureKey = goog.getUid(feature) + '';
+ goog.asserts.assert(!(featureKey in this.featureChangeKeys_));
+ this.featureChangeKeys_[featureKey] = goog.events.listen(feature,
+ goog.events.EventType.CHANGE, this.handleFeatureChange_, false, this);
+ var extent = feature.getGeometry().getExtent();
+ this.rTree_.insert(extent, feature);
+ this.dispatchChangeEvent();
+};
+
+
+/**
+ * @param {ol.Extent} extent Extent.
+ * @return {Array.} Features.
+ */
+ol.source.Vector.prototype.getFeatures = function(extent) {
+ return this.rTree_.search(extent);
+};
+
+
+/**
+ * @param {goog.events.Event} event Event.
+ * @private
+ */
+ol.source.Vector.prototype.handleFeatureChange_ = function(event) {
+ //var feature = /** @type {ol.Feature} */ (event.target);
+ // FIXME keep R-Tree up to date
+ this.dispatchChangeEvent();
+};
+
+
+/**
+ * @param {ol.Feature} feature Feature.
+ */
+ol.source.Vector.prototype.removeFeature = function(feature) {
+ var extent = feature.getGeometry().getExtent();
+ this.rTree_.remove(extent, feature);
+ var featureKey = goog.getUid(feature) + '';
+ goog.asserts.assert(featureKey in this.featureChangeKeys_);
+ goog.events.unlistenByKey(this.featureChangeKeys_[featureKey]);
+ delete this.featureChangeKeys_[featureKey];
+ this.dispatchChangeEvent();
+};
diff --git a/test/spec/ol/source/vectorsource.test.js b/test/spec/ol/source/vectorsource.test.js
new file mode 100644
index 0000000000..3c164fb381
--- /dev/null
+++ b/test/spec/ol/source/vectorsource.test.js
@@ -0,0 +1,141 @@
+goog.provide('ol.test.source.Vector');
+
+
+describe('ol.source.Vector', function() {
+
+ var pointFeature;
+ var infiniteExtent;
+ beforeEach(function() {
+ pointFeature = new ol.Feature(new ol.geom.Point([0, 0]));
+ infiniteExtent = [-Infinity, -Infinity, Infinity, Infinity];
+ });
+
+ describe('when empty', function() {
+
+ var vectorSource;
+ beforeEach(function() {
+ vectorSource = new ol.source.Vector();
+ });
+
+ describe('#getFeatures', function() {
+
+ it('returns an empty array', function() {
+ var features = vectorSource.getFeatures(infiniteExtent);
+ expect(features).to.be.an(Array);
+ expect(features).to.be.empty();
+ });
+
+ });
+
+ describe('#addFeature', function() {
+
+ it('can add a single point feature', function() {
+ vectorSource.addFeature(pointFeature);
+ var features = vectorSource.getFeatures(infiniteExtent);
+ expect(features).to.be.an(Array);
+ expect(features).to.have.length(1);
+ expect(features[0]).to.be(pointFeature);
+ });
+
+ it('fires a change event', function() {
+ var listener = sinon.spy();
+ goog.events.listen(vectorSource, 'change', listener);
+ vectorSource.addFeature(pointFeature);
+ expect(listener).to.be.called();
+ });
+
+ });
+
+ });
+
+ describe('when populated with 10 random points', function() {
+
+ var features;
+ var vectorSource;
+ beforeEach(function() {
+ features = [];
+ var i;
+ for (i = 0; i < 10; ++i) {
+ features[i] =
+ new ol.Feature(new ol.geom.Point([Math.random(), Math.random()]));
+ }
+ vectorSource = new ol.source.Vector({
+ features: features
+ });
+ });
+
+ describe('#getFeatures', function() {
+
+ it('returns the expected number of features', function() {
+ expect(vectorSource.getFeatures(infiniteExtent)).have.length(10);
+ });
+
+ });
+
+ describe('#removeFeature', function() {
+
+ it('works as expected', function() {
+ var i;
+ for (i = features.length - 1; i >= 0; --i) {
+ vectorSource.removeFeature(features[i]);
+ expect(vectorSource.getFeatures(infiniteExtent)).have.length(i);
+ }
+ });
+
+ it('fires a change event', function() {
+ var listener = sinon.spy();
+ goog.events.listen(vectorSource, 'change', listener);
+ vectorSource.removeFeature(features[0]);
+ expect(listener).to.be.called();
+ });
+
+ });
+
+ describe('modifying a feature\'s geometry', function() {
+
+ it('fires a change event', function() {
+ var listener = sinon.spy();
+ goog.events.listen(vectorSource, 'change', listener);
+ features[0].getGeometry().setCoordinate([100, 100]);
+ expect(listener).to.be.called();
+ });
+
+ if (false) {
+ it('keeps the R-Tree index up to date', function() {
+ expect(vectorSource.getFeatures([0, 0, 1, 1])).to.have.length(10);
+ features[0].getGeometry().setCoordinate([100, 100]);
+ expect(vectorSource.getFeatures([0, 0, 1, 1])).to.have.length(9);
+ features[0].getGeometry().setCoordinate([0.5, 0.5]);
+ expect(vectorSource.getFeatures([0, 0, 1, 1])).to.have.length(10);
+ });
+ }
+
+ });
+
+ describe('setting a features geometry', function() {
+
+ it('fires a change event', function() {
+ var listener = sinon.spy();
+ goog.events.listen(vectorSource, 'change', listener);
+ features[0].setGeometry(new ol.geom.Point([100, 100]));
+ expect(listener).to.be.called();
+ });
+
+ if (false) {
+ it('keeps the R-Tree index up to date', function() {
+ expect(vectorSource.getFeatures([0, 0, 1, 1])).to.have.length(10);
+ features[0].setGeometry(new ol.geom.Point([100, 100]));
+ expect(vectorSource.getFeatures([0, 0, 1, 1])).to.have.length(9);
+ });
+ }
+ });
+
+ });
+
+});
+
+
+goog.require('goog.events');
+goog.require('ol.Feature');
+goog.require('ol.geom.Point');
+goog.require('ol.source.Vector');