var inherit = require('./inherit');
var feature = require('./feature');
var pointFeature = require('./pointFeature');
/**
* Object specification for a marker feature.
*
* @typedef {geo.feature.spec} geo.markerFeature.spec
* @extends geo.feature.spec
* @property {geo.geoPosition|function} [position] Position of the data.
* Default is (data).
* @property {geo.markerFeature.styleSpec} [style] Style object with default
* style options.
*/
/**
* Style specification for a marker feature.
*
* @typedef {geo.feature.styleSpec} geo.markerFeature.styleSpec
* @extends geo.feature.styleSpec
* @property {number|function} [radius=5] Radius of each marker in pixels.
* This includes the stroke width if `strokeOffset` is -1, excludes it if
* `strokeOffset` is 1, and includes half the stroke width if `strokeOffset`
* is 0. Note that is `radiusIncludesStroke` is `false`, this never
* includes the stroke width.
* @property {geo.geoColor|function} [strokeColor] Color to stroke each marker.
* @property {number|function} [strokeOpacity=1] Opacity for each marker's
* stroke. Opacity is on a [0-1] scale. Set this or `strokeWidth` to zero
* to not have a stroke.
* @property {number|function} [strokeWidth=1.25] The weight of the marker's
* stroke in pixels. Set this or `strokeOpacity` to zero to not have a
* stroke.
* @property {number|function} [strokeOffset=-1] The position of the stroke
* compared to the radius. This can only be -1, 0, or 1 (the sign of the
* value is used).
* @property {boolean|function} [radiusIncludesStroke=true] If truthy or
* undefined, the `radius` includes the `strokeWidth` based on the
* `strokeOffset`. If defined and falsy, the radius does not include the
* `strokeWidth`.
* @property {geo.geoColor|function} [fillColor] Color to fill each marker.
* @property {number|function} [fillOpacity=1] Opacity for each marker.
* Opacity is on a [0-1] scale. Set to zero to have no fill.
* @property {number|function} [symbol=0] One of the predefined symbol numbers.
* This is one of `geo.markerFeature.symbols`.
* @property {number|number[]|function} [symbolValue=0] A value the affects the
* appearance of the symbol. Some symbols can take an array of numbers.
* @property {number|function} [rotation=0] The rotation of the symbol in
* clockwise radians.
* @property {geo.markerFeature.scaleMode|function} [scaleWithZoom='none'] This
* determines if the fill, stroke, or both scale with zoom. If set, the
* values for radius and strokeWidth are the values at zoom-level zero.
* @property {boolean|function} [rotateWithMap=false] If truthy, rotate symbols
* with the map. If falsy, symbol orientation is absolute.
* @property {number[]|function} [origin] Origin in map gcs coordinates used
* to ensure high precision drawing in this location. When called as a
* function, this is passed the maker positions as a single continuous array
* in map gcs coordinates. It defaults to the first marker's position.
*/
/**
* Create a new instance of class markerFeature.
*
* @class
* @alias geo.markerFeature
* @extends geo.feature
* @param {geo.markerFeature.spec} arg
* @returns {geo.markerFeature}
*/
var markerFeature = function (arg) {
'use strict';
if (!(this instanceof markerFeature)) {
return new markerFeature(arg);
}
arg = arg || {};
pointFeature.call(this, arg);
var timestamp = require('./timestamp');
var util = require('./util');
var KDBush = require('kdbush');
KDBush = KDBush.__esModule ? KDBush.default : KDBush;
/**
* @private
*/
var m_this = this,
s_init = this._init,
m_rangeTree = null,
m_rangeTreeTime = timestamp(),
m_maxFixedRadius = 0,
m_maxZoomRadius = 0,
m_maxZoomStroke = 0;
this.featureType = 'marker';
/**
* Update the current range tree object. Should be called whenever the
* data changes.
*/
this._updateRangeTree = function () {
if (m_rangeTreeTime.timestamp() >= m_this.dataTime().timestamp() && m_rangeTreeTime.timestamp() >= m_this.timestamp()) {
return;
}
var pts, position,
radius = m_this.style.get('radius'),
strokeWidth = m_this.style.get('strokeWidth'),
radiusIncludesStroke = m_this.style.get('radiusIncludesStroke'),
strokeOffset = m_this.style.get('strokeOffset'),
scaleWithZoom = m_this.style.get('scaleWithZoom');
position = m_this.position();
m_maxFixedRadius = 0;
m_maxZoomRadius = 0;
m_maxZoomStroke = 0;
// create an array of positions in geo coordinates
pts = m_this.data().map(function (d, i) {
var pt = position(d, i);
let swz = scaleWithZoom(d, i);
const r = radius(d, i),
s = strokeWidth(d, i),
so = Math.sign(strokeOffset(d, i));
let ris = radiusIncludesStroke(d, i);
ris = ris === undefined ? true : ris;
const rwiths = ris ? r + s * (so + 1) / 2 : r + s, // radius with stroke
rwos = ris ? r + s * (so - 1) / 2 : r; // radius without stroke
swz = markerFeature.scaleMode[swz] || (swz >= 1 && swz <= 3 ? swz : 0);
switch (swz) {
case markerFeature.scaleMode.stroke:
if (rwos > m_maxFixedRadius) {
m_maxFixedRadius = rwos;
}
if (s > m_maxZoomStroke) {
m_maxZoomStroke = s;
}
break;
case markerFeature.scaleMode.fill:
case markerFeature.scaleMode.all:
if (rwiths > m_maxZoomRadius) {
m_maxZoomRadius = rwiths;
}
break;
default:
if (rwiths > m_maxFixedRadius) {
m_maxFixedRadius = rwiths;
}
break;
}
return [pt.x, pt.y];
});
m_rangeTree = new KDBush(pts.length);
for (const [x, y] of pts) {
m_rangeTree.add(x, y);
}
m_rangeTree.finish();
m_rangeTreeTime.modified();
};
/**
* Determine an approximate maximum radius based on the zoom factor.
*
* @param {number} zoom The zoom level.
* @returns {number} The maximum radius. May be somewhat larger than the
* actual maximum.
*/
this._approximateMaxRadius = function (zoom) {
m_this._updateRangeTree();
const zoomFactor = Math.pow(2, zoom);
return Math.max(m_maxFixedRadius + m_maxZoomStroke * zoomFactor, m_maxZoomRadius * zoomFactor);
};
/**
* Returns an array of datum indices that contain the given marker.
*
* @param {geo.geoPosition} p marker to search for.
* @param {string|geo.transform|null} [gcs] Input gcs. `undefined` to use
* the interface gcs, `null` to use the map gcs, or any other transform.
* @returns {object} An object with `index`: a list of marker indices, and
* `found`: a list of markers that contain the specified coordinate.
*/
this.pointSearch = function (p, gcs) {
var min, max, data, idx = [], found = [], ifound = [],
fgcs = m_this.gcs(), // this feature's gcs
corners,
radius = m_this.style.get('radius'),
strokeWidth = m_this.style.get('strokeWidth'),
radiusIncludesStroke = m_this.style.get('radiusIncludesStroke'),
strokeOffset = m_this.style.get('strokeOffset'),
scaleWithZoom = m_this.style.get('scaleWithZoom');
data = m_this.data();
if (!data || !data.length) {
return {
found: [],
index: []
};
}
// We need to do this before we find corners, since the max radius is
// determined then
m_this._updateRangeTree();
var map = m_this.layer().map();
gcs = (gcs === null ? map.gcs() : (gcs === undefined ? map.ingcs() : gcs));
var pt = map.gcsToDisplay(p, gcs),
zoom = map.zoom(),
zoomFactor = Math.pow(2, zoom),
maxr = this._approximateMaxRadius(zoom);
// check all corners to make sure we handle rotations
corners = [
map.displayToGcs({x: pt.x - maxr, y: pt.y - maxr}, fgcs),
map.displayToGcs({x: pt.x + maxr, y: pt.y - maxr}, fgcs),
map.displayToGcs({x: pt.x - maxr, y: pt.y + maxr}, fgcs),
map.displayToGcs({x: pt.x + maxr, y: pt.y + maxr}, fgcs)
];
min = {
x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
};
max = {
x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
};
// Find markers inside the bounding box
idx = m_rangeTree.range(min.x, min.y, max.x, max.y);
idx = Uint32Array.from(idx).sort();
// Filter by circular region
idx.forEach(function (i) {
var d = data[i],
rad = radius(data[i], i),
swz = scaleWithZoom(data[i], i),
so = strokeOffset(data[i], i),
s = swz ? strokeWidth(data[i], i) : 0;
let ris = radiusIncludesStroke(d, i);
ris = ris === undefined ? true : ris;
var rwos = ris ? rad + s * (so - 1) / 2 : rad; // radius without stroke
rad = rwos + s;
var p = m_this.position()(d, i),
dx, dy, rad2;
swz = markerFeature.scaleMode[swz] || (swz >= 1 && swz <= 3 ? swz : 0);
switch (swz) {
case markerFeature.scaleMode.fill:
rad = rwos * zoomFactor + s;
break;
case markerFeature.scaleMode.stroke:
rad = rwos + s * zoomFactor;
break;
case markerFeature.scaleMode.all:
rad *= zoomFactor;
break;
}
if (rad) {
rad2 = rad * rad;
p = map.gcsToDisplay(p, fgcs);
dx = p.x - pt.x;
dy = p.y - pt.y;
if (dx * dx + dy * dy <= rad2) {
found.push(d);
ifound.push(i);
}
}
});
return {
found: found,
index: ifound
};
};
/**
* Returns an array of datum indices that are contained in the given polygon.
* This does not take clustering into account.
*
* @param {geo.polygonObject} poly A polygon as an array of coordinates or an
* object with `outer` and optionally `inner` parameters.
* @param {object} [opts] Additional search options.
* @param {boolean} [opts.partial=false] If truthy, include markers that are
* partially in the polygon, otherwise only include markers that are fully
* within the region. If 'center', only markers whose centers are inside
* the polygon are returned.
* @param {string|geo.transform|null} [gcs] Input gcs. `undefined` to use
* the interface gcs, `null` to use the map gcs, or any other transform.
* @returns {object} An object with `index`: a list of marker indices,
* `found`: a list of markers within the polygon, and `extra`: an object
* with index keys containing an object with a `partial` key and a boolean
* value to indicate if the marker is on the polygon's border and a
* `distance` key to indicate how far within the polygon the marker is
* located.
*/
this.polygonSearch = function (poly, opts, gcs) {
var fgcs = m_this.gcs(), // this feature's gcs
found = [],
ifound = [],
extra = {},
map = m_this.layer().map(),
data = m_this.data(),
radius = m_this.style.get('radius'),
strokeWidth = m_this.style.get('strokeWidth'),
radiusIncludesStroke = m_this.style.get('radiusIncludesStroke'),
strokeOffset = m_this.style.get('strokeOffset'),
scaleWithZoom = m_this.style.get('scaleWithZoom'),
idx, min, max, corners,
zoom = map.zoom(),
zoomFactor = Math.pow(2, zoom),
maxr = this._approximateMaxRadius(zoom);
gcs = (gcs === null ? map.gcs() : (gcs === undefined ? map.ingcs() : gcs));
if (!poly.outer) {
poly = {outer: poly, inner: []};
}
if (poly.outer.length < 3 || !data || !data.length) {
return {
found: [],
index: [],
extra: {}
};
}
opts = opts || {};
opts.partial = opts.partial || false;
poly = {outer: map.gcsToDisplay(poly.outer, gcs), inner: (poly.inner || []).map(inner => map.gcsToDisplay(inner, gcs))};
poly.outer.forEach(p => {
if (!min) {
min = {x: p.x, y: p.y};
max = {x: p.x, y: p.y};
}
if (p.x < min.x) { min.x = p.x; }
if (p.x > max.x) { max.x = p.x; }
if (p.y < min.y) { min.y = p.y; }
if (p.y > max.y) { max.y = p.y; }
});
// We need to do this before we find corners, since the max radius is
// determined then
m_this._updateRangeTree();
corners = [
map.displayToGcs({x: min.x - maxr, y: min.y - maxr}, fgcs),
map.displayToGcs({x: max.x + maxr, y: min.y - maxr}, fgcs),
map.displayToGcs({x: max.x + maxr, y: max.y + maxr}, fgcs),
map.displayToGcs({x: min.x - maxr, y: max.y + maxr}, fgcs)
];
min = {
x: Math.min(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.min(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
};
max = {
x: Math.max(corners[0].x, corners[1].x, corners[2].x, corners[3].x),
y: Math.max(corners[0].y, corners[1].y, corners[2].y, corners[3].y)
};
// Find markers inside the bounding box. Only these could be in the polygon
idx = m_rangeTree.range(min.x, min.y, max.x, max.y);
/* sort by index. This had been
* idx.sort((a, b) => a - b);
* but this requires continual casting from int to str and back, so using
* a Uint32Array is faster, though potentially limits the maximum number of
* markers. */
idx = Uint32Array.from(idx).sort();
// filter markers within the polygon
idx.forEach(function (i) {
var d = data[i];
let p = m_this.position()(d, i);
let rad = radius(d, i),
swz = scaleWithZoom(d, i);
const so = strokeOffset(d, i),
s = swz ? strokeWidth(d, i) : 0;
let ris = radiusIncludesStroke(d, i);
ris = ris === undefined ? true : ris;
const rwos = ris ? rad + s * (so - 1) / 2 : rad; // radius without stroke
swz = markerFeature.scaleMode[swz] || (swz >= 1 && swz <= 3 ? swz : 0);
rad = rwos + s;
switch (swz) {
case markerFeature.scaleMode.fill:
rad = rwos * zoomFactor + s;
break;
case markerFeature.scaleMode.stroke:
rad = rwos + s * zoomFactor;
break;
case markerFeature.scaleMode.all:
rad *= zoomFactor;
break;
}
if (rad) {
p = map.gcsToDisplay(p, fgcs);
const dist = util.distanceToPolygon2d(p, poly);
if (dist >= rad || (dist >= 0 && opts.partial === 'center') || (dist >= -rad && opts.partial && opts.partial !== 'center')) {
found.push(d);
ifound.push(i);
extra[i] = {partial: dist < rad, distance: dist};
}
}
});
return {
found: found,
index: ifound,
extra: extra
};
};
/**
* Initialize.
*
* @param {geo.markerFeature.spec} arg The feature specification.
* @returns {this}
*/
this._init = function (arg) {
arg = util.deepMerge(
{},
{
style: Object.assign(
{},
{
radius: 6.25,
radiusIncludesStroke: true,
strokeColor: { r: 0.851, g: 0.604, b: 0.0 },
strokeOffset: -1.0,
strokeOpacity: 1.0,
strokeWidth: 1.25,
fillColor: { r: 1.0, g: 0.839, b: 0.439 },
fillOpacity: 0.8,
symbol: 0,
symbolValue: 0,
rotation: 0,
scaleWithZoom: markerFeature.scaleMode.none,
rotateWithMap: false
// position and origin are the same as the pointFeature
},
arg && arg.style === undefined ? {} : arg.style
)
},
arg
);
s_init.call(m_this, arg);
return m_this;
};
return m_this;
};
/**
* Create a markerFeature from an object.
* @see {@link geo.feature.create}
* @param {geo.layer} layer The layer to add the feature to
* @param {geo.markerFeature.spec} spec The object specification
* @returns {geo.markerFeature|null}
*/
markerFeature.create = function (layer, spec) {
'use strict';
spec = spec || {};
spec.type = 'marker';
return feature.create(layer, spec);
};
markerFeature.capabilities = {
/* core feature name -- support in any manner */
feature: 'marker'
};
markerFeature.primitiveShapes = pointFeature.primitiveShapes;
/**
* Marker symbols
* @enum {number}
*/
markerFeature.symbols = {
// for circle (alias ellipse), the symbolValue is the ratio of the minor to
// major axes. Range (0, infinity)
circle: 0,
ellipse: 0,
flowerBase: 1,
flowerMax: 16,
// for triangle, the symbolValue is the ratio of the base to the other sides.
// Ranges (0, 2)
triangle: 16,
diamond: 17,
starBase: 17,
starMax: 16,
// for square (alias rectangle), the symbolValue is the ratio of the minor to
// major axes. Range (0, infinity)
square: 32,
rectangle: 32,
// for crosses, the symbolValue is the width of the arm compared to the
// length of the cross
crossBase: 33,
crossMax: 16,
// for ovals, the symbolValue is the ratio of the minor to major axes. Range
// (0, 1]
oval: 48,
jackBase: 49,
jackMax: 16,
// for drops, the symbol value is the ratio of the arc to the main radius.
// Range (0, 1]
drop: 64,
dropBase: 65,
dropMax: 16,
// for arrow, the symbol value is an array of up to four values:
// headWidth : the ratio of the head width to the radius. Range (0, 1].
// Default 2/3.
// headLength : the ratio of head length to the diameter. Range (0, 1].
// Default 1/2.
// stemWidth : the ratio of the stem width to the head width. Range
// [0, 1]. Default 1/3.
// sweep : a boolean; if true the back of head is swept; if false the back
// of the head is square. Default false.
arrow: 80,
arrowBase: 81,
arrowMax: 16,
length: 96
// possible other symbols:
// half inner stellations (bowtie/hourglass), hash (#), inner curved shapes
};
['flower', 'star', 'cross', 'jack', 'drop', 'arrow'].forEach(key => {
for (let i = 2; i <= markerFeature.symbols[key + 'Max']; i += 1) {
markerFeature.symbols[key + i] = markerFeature.symbols[key + 'Base'] - 2 + i;
}
});
/**
* Marker scale modes
* @enum {number}
*/
markerFeature.scaleMode = {
none: 0,
fill: 1,
stroke: 2,
all: 3
};
inherit(markerFeature, pointFeature);
module.exports = markerFeature;