transitive-js
Version:
A tool for generating dynamic stylized transit maps that are easy to understand.
1,989 lines (1,638 loc) • 187 kB
JavaScript
'use strict';
Object.defineProperty(exports, '__esModule', { value: true });
function _interopDefault (ex) { return (ex && (typeof ex === 'object') && 'default' in ex) ? ex['default'] : ex; }
var d3 = _interopDefault(require('d3'));
var Emitter = _interopDefault(require('component-emitter'));
var lodash = require('lodash');
var SphericalMercator = _interopDefault(require('sphericalmercator'));
var PriorityQueue = _interopDefault(require('priorityqueuejs'));
var SVG = _interopDefault(require('svg.js'));
var roundedRect = _interopDefault(require('rounded-rect'));
var measureText = _interopDefault(require('measure-text'));
/**
* General Transitive utilities library
*/
const TOLERANCE = 0.000001;
function fuzzyEquals(a, b, tolerance = TOLERANCE) {
return Math.abs(a - b) < tolerance;
}
function distance(x1, y1, x2, y2) {
return Math.sqrt((x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1));
}
function getRadiusFromAngleChord(angleR, chordLen) {
return chordLen / 2 / Math.sin(angleR / 2);
}
/*
* CCW utility function. Accepts 3 coord pairs; result is positive if points
* have counterclockwise orientation, negative if clockwise, 0 if collinear.
*/
function ccw(ax, ay, bx, by, cx, cy) {
const raw = ccwRaw(ax, ay, bx, by, cx, cy);
return raw === 0 ? 0 : raw / Math.abs(raw);
}
function ccwRaw(ax, ay, bx, by, cx, cy) {
return (bx - ax) * (cy - ay) - (cx - ax) * (by - ay);
}
/*
* Compute angle formed by three points in cartesian plane using law of cosines
*/
function angleFromThreePoints(ax, ay, bx, by, cx, cy) {
const c = distance(ax, ay, bx, by);
const a = distance(bx, by, cx, cy);
const b = distance(ax, ay, cx, cy);
return Math.acos((a * a + c * c - b * b) / (2 * a * c));
}
function pointAlongArc(x1, y1, x2, y2, r, theta, ccw, t) {
ccw = Math.abs(ccw) / ccw; // convert to 1 or -1
let rot = Math.PI / 2 - Math.abs(theta) / 2;
const vectToCenter = normalizeVector(rotateVector({
x: x2 - x1,
y: y2 - y1
}, ccw * rot)); // calculate the center of the arc circle
const cx = x1 + r * vectToCenter.x;
const cy = y1 + r * vectToCenter.y;
let vectFromCenter = negateVector(vectToCenter);
rot = Math.abs(theta) * t * ccw;
vectFromCenter = normalizeVector(rotateVector(vectFromCenter, rot));
return {
x: cx + r * vectFromCenter.x,
y: cy + r * vectFromCenter.y
};
}
function getVectorAngle(x, y) {
let t = Math.atan(y / x);
if (x < 0 && t <= 0) t += Math.PI;else if (x < 0 && t >= 0) t -= Math.PI;
return t;
}
function rayIntersection(ax, ay, avx, avy, bx, by, bvx, bvy) {
const u = ((by - ay) * bvx - (bx - ax) * bvy) / (bvx * avy - bvy * avx);
const v = ((by - ay) * avx - (bx - ax) * avy) / (bvx * avy - bvy * avx);
return {
intersect: u > -TOLERANCE && v > -TOLERANCE,
u: u,
v: v
};
}
function lineIntersection(x1, y1, x2, y2, x3, y3, x4, y4) {
const d = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
if (d === 0) {
// lines are parallel
return {
intersect: false
};
}
return {
intersect: true,
x: ((x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4)) / d,
y: ((x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4)) / d
};
}
/**
* Whether vector is projected into positive xy quadrant.
*/
function isOutwardVector(vector) {
return !fuzzyEquals(vector.x, 0) ? vector.x > 0 : vector.y > 0;
}
/**
* vector utilities
*/
function normalizeVector(v) {
const d = Math.sqrt(v.x * v.x + v.y * v.y);
return {
x: v.x / d,
y: v.y / d
};
}
function rotateVector(v, theta) {
return {
x: v.x * Math.cos(theta) - v.y * Math.sin(theta),
y: v.x * Math.sin(theta) + v.y * Math.cos(theta)
};
}
function negateVector(v) {
return {
x: -v.x,
y: -v.y
};
}
/**
* GTFS utilities
*/
function otpModeToGtfsType(otpMode) {
switch (otpMode) {
case 'TRAM':
return 0;
case 'SUBWAY':
return 1;
case 'RAIL':
return 2;
case 'BUS':
return 3;
case 'FERRY':
return 4;
case 'CABLE_CAR':
return 5;
case 'GONDOLA':
return 6;
case 'FUNICULAR':
return 7;
}
} // Rendering utilities
function renderDataToSvgPath(renderData) {
return renderData.map((d, k) => {
if (k === 0) return `M${d.x} ${d.y}`;
if (d.arc) {
return `A${d.radius} ${d.radius} ${d.arc} 0 ${d.arc > 0 ? 0 : 1} ${d.x} ${d.y}`;
}
return `L${d.x} ${d.y}`;
}).join(' ');
} // An instance of the SphericalMercator converter
const sm = /*#__PURE__*/new SphericalMercator();
/**
* @param {*} fontSize A CSS font size or a numerical (pixel) font size.
* @returns A CSS font size ending with the provided CSS unit or 'px' if none provided.
*/
function getFontSizeWithUnit(fontSize) {
return fontSize + (isFinite(fontSize) ? 'px' : '');
}
/**
* Label object
*/
class Label {
constructor(parent) {
this.parent = parent;
this.sortableType = 'LABEL';
}
getText() {
if (!this.labelText) this.labelText = this.initText();
return this.labelText;
}
initText() {
// TODO: determine why getName is missing for patterns running on routes
// without short names
return typeof this.parent.getName === 'function' ? this.parent.getName() : null;
}
render(display) {
throw new Error('method not defined by subclass!');
}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
refresh(display) {}
setVisibility(visibility) {
if (this.svgGroup) {
this.svgGroup.attr('display', visibility ? 'initial' : 'none');
}
}
getBBox() {
return null;
}
intersects(obj) {
return null;
}
intersectsBBox(bbox) {
const thisBBox = this.getBBox(this.orientation);
const r = thisBBox.x <= bbox.x + bbox.width && bbox.x <= thisBBox.x + thisBBox.width && thisBBox.y <= bbox.y + bbox.height && bbox.y <= thisBBox.y + thisBBox.height;
return r;
}
isFocused() {
return this.parent.isFocused();
}
getZIndex() {
return 1000000;
}
}
/**
* Label object
*/
class PointLabel extends Label {
constructor(parent) {
super(parent);
this.labelAngle = 0;
this.labelPosition = 1;
}
initText() {
return this.parent.getName();
}
/* render (display) {
this.svgGroup = display.svg.append('g') // this.parent.labelSvg;
this.svgGroup
.attr('class', 'transitive-sortable')
.datum({
owner: this,
sortableType: 'POINT_LABEL'
})
var typeStr = this.parent.getType().toLowerCase()
this.mainLabel = this.svgGroup.append('text')
.datum({
owner: this
})
.attr('id', 'transitive-' + typeStr + '-label-' + this.parent.getId())
.text(this.getText())
.attr('font-size', this.fontSize)
.attr('font-family', this.fontFamily)
.attr('class', 'transitive-' + typeStr + '-label')
}
refresh (display) {
if (!this.labelAnchor) return
if (!this.svgGroup) this.render(display)
this.svgGroup
.attr('text-anchor', this.labelPosition > 0 ? 'start' : 'end')
.attr('transform', (d, i) => {
return 'translate(' + this.labelAnchor.x + ',' + this.labelAnchor
.y + ')'
})
this.mainLabel
.attr('transform', (d, i) => {
return 'rotate(' + this.labelAngle + ', 0, 0)'
})
} */
render(display) {
const text = this.getText();
if (!text || !this.labelAnchor) return;
const anchor = {
x: this.labelAnchor.x,
y: this.labelAnchor.y - this.textHeight / 2
}; // define common style attributes for the halo and main text
const attrs = {
fill: '#000',
'font-family': display.styler.compute2('labels', 'font-family', this.parent),
'font-size': getFontSizeWithUnit(this.fontSize),
'text-anchor': this.labelPosition > 0 ? 'start' : 'end'
}; // draw the halo
display.drawText(text, anchor, Object.assign({}, attrs, {
fill: 'none',
stroke: '#fff',
'stroke-opacity': 0.75,
'stroke-width': 3
})); // draw the main text
display.drawText(text, anchor, attrs);
}
setOrientation(orientation) {
this.orientation = orientation;
const markerBBox = this.parent.getMarkerBBox();
if (!markerBBox) return;
let x, y;
const offset = 5;
if (orientation === 'E') {
x = markerBBox.x + markerBBox.width + offset;
y = markerBBox.y + markerBBox.height / 2;
this.labelPosition = 1;
this.labelAngle = 0;
} else if (orientation === 'W') {
x = markerBBox.x - offset;
y = markerBBox.y + markerBBox.height / 2;
this.labelPosition = -1;
this.labelAngle = 0;
} else if (orientation === 'NE') {
x = markerBBox.x + markerBBox.width + offset;
y = markerBBox.y - offset;
this.labelPosition = 1;
this.labelAngle = -45;
} else if (orientation === 'SE') {
x = markerBBox.x + markerBBox.width + offset;
y = markerBBox.y + markerBBox.height + offset;
this.labelPosition = 1;
this.labelAngle = 45;
} else if (orientation === 'NW') {
x = markerBBox.x - offset;
y = markerBBox.y - offset;
this.labelPosition = -1;
this.labelAngle = 45;
} else if (orientation === 'SW') {
x = markerBBox.x - offset;
y = markerBBox.y + markerBBox.height + offset;
this.labelPosition = -1;
this.labelAngle = -45;
} else if (orientation === 'N') {
x = markerBBox.x + markerBBox.width / 2;
y = markerBBox.y - offset;
this.labelPosition = 1;
this.labelAngle = -90;
} else if (orientation === 'S') {
x = markerBBox.x + markerBBox.width / 2;
y = markerBBox.y + markerBBox.height + offset;
this.labelPosition = -1;
this.labelAngle = -90;
}
this.labelAnchor = {
x: x,
y: y
};
}
getBBox() {
if (this.orientation === 'E') {
return {
height: this.textHeight,
width: this.textWidth,
x: this.labelAnchor.x,
y: this.labelAnchor.y - this.textHeight
};
}
if (this.orientation === 'W') {
return {
height: this.textHeight,
width: this.textWidth,
x: this.labelAnchor.x - this.textWidth,
y: this.labelAnchor.y - this.textHeight
};
}
if (this.orientation === 'N') {
return {
height: this.textWidth,
width: this.textHeight,
x: this.labelAnchor.x - this.textHeight,
y: this.labelAnchor.y - this.textWidth
};
}
if (this.orientation === 'S') {
return {
height: this.textWidth,
width: this.textHeight,
x: this.labelAnchor.x - this.textHeight,
y: this.labelAnchor.y
};
}
const bboxSide = this.textWidth * Math.sqrt(2) / 2;
if (this.orientation === 'NE') {
return {
height: bboxSide,
width: bboxSide,
x: this.labelAnchor.x,
y: this.labelAnchor.y - bboxSide
};
}
if (this.orientation === 'SE') {
return {
height: bboxSide,
width: bboxSide,
x: this.labelAnchor.x,
y: this.labelAnchor.y
};
}
if (this.orientation === 'NW') {
return {
height: bboxSide,
width: bboxSide,
x: this.labelAnchor.x - bboxSide,
y: this.labelAnchor.y - bboxSide
};
}
if (this.orientation === 'SW') {
return {
height: bboxSide,
width: bboxSide,
x: this.labelAnchor.x - bboxSide,
y: this.labelAnchor.y
};
}
}
intersects(obj) {
if (obj instanceof Label) {
// todo: handle label-label intersection for diagonally placed labels separately
return this.intersectsBBox(obj.getBBox());
} else if (obj.x && obj.y && obj.width && obj.height) {
return this.intersectsBBox(obj);
}
return false;
}
runFocusTransition(display, callback) {
if (this.mainLabel) {
if (this.parent.isFocused()) this.setVisibility(true);
this.mainLabel.transition().style('opacity', this.parent.isFocused() ? 1 : 0).call(callback);
}
}
}
class Point {
constructor(data) {
for (const key in data) {
this[key] = data[key];
}
this.paths = [];
this.renderData = [];
this.label = new PointLabel(this);
this.renderLabel = true;
this.focused = true;
this.sortableType = 'POINT';
this.placeOffsets = {
x: 0,
y: 0
};
this.zIndex = 10000;
}
/**
* Get unique ID for point -- must be defined by subclass
*/
getId() {
throw new Error('method not defined by subclass!');
}
getElementId() {
return this.getType().toLowerCase() + '-' + this.getId();
}
/**
* Get Point type -- must be defined by subclass
*/
getType() {
throw new Error('method not defined by subclass!');
}
/**
* Get Point name
*/
getName() {
return `${this.getType()} point (ID=${this.getId()})`;
}
/**
* Get latitude
*/
getLat() {
return 0;
}
/**
* Get longitude
*/
getLon() {
return 0;
}
containsSegmentEndPoint() {
return false;
}
containsBoardPoint() {
return false;
}
containsAlightPoint() {
return false;
}
containsTransferPoint() {
return false;
}
getPatterns() {
return [];
}
/**
* Draw the point
*
* @param {Display} display
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
render(display) {}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
addRenderData() {}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
clearRenderData() {}
containsFromPoint() {
return false;
}
containsToPoint() {
return false;
} //* * Shared geom utility functions **//
constructMergedMarker(display) {
const dataArray = this.getRenderDataArray();
const xValues = [];
const yValues = [];
dataArray.forEach(function (data) {
const x = data.x;
const y = data.y;
xValues.push(x);
yValues.push(y);
});
const minX = Math.min.apply(Math, xValues);
const minY = Math.min.apply(Math, yValues);
const maxX = Math.max.apply(Math, xValues);
const maxY = Math.max.apply(Math, yValues); // retrieve marker type and radius from the styler
const markerType = display.styler.compute(display.styler.stops_merged['marker-type'], display, {
owner: this
});
const stylerRadius = display.styler.compute(display.styler.stops_merged.r, display, {
owner: this
});
let width;
let height;
let r; // if this is a circle marker w/ a styler-defined fixed radius, use that
if (markerType === 'circle' && stylerRadius) {
width = height = stylerRadius * 2;
r = stylerRadius; // otherwise, this is a dynamically-sized marker
} else {
const dx = maxX - minX;
const dy = maxY - minY;
const markerPadding = display.styler.compute(display.styler.stops_merged['marker-padding'], display, {
owner: this
}) || 0;
const patternRadius = display.styler.compute(display.styler[this.patternStylerKey].r, display, {
owner: this
});
r = parseFloat(patternRadius) + markerPadding;
if (markerType === 'circle') {
width = height = Math.max(dx, dy) + 2 * r;
r = width / 2;
} else {
width = dx + 2 * r;
height = dy + 2 * r;
if (markerType === 'rectangle') r = 0;
}
}
return {
height: height,
rx: r,
ry: r,
width: width,
x: (minX + maxX) / 2 - width / 2,
y: (minY + maxY) / 2 - height / 2
};
}
initMarkerData(display) {
if (this.getType() !== 'STOP' && this.getType() !== 'MULTI') return;
this.mergedMarkerData = this.constructMergedMarker(display);
this.placeOffsets = {
x: 0,
y: 0
};
if (this.adjacentPlace) {
const placeR = display.styler.compute(display.styler.places.r, display, {
owner: this.adjacentPlace
});
const placeX = display.xScale.compute(this.adjacentPlace.worldX);
const placeY = display.yScale.compute(this.adjacentPlace.worldY);
const thisR = this.mergedMarkerData.width / 2;
const thisX = this.mergedMarkerData.x + thisR;
const thisY = this.mergedMarkerData.y + thisR;
const dx = thisX - placeX;
const dy = thisY - placeY;
const dist = Math.sqrt(dx * dx + dy * dy);
if (placeR + thisR > dist) {
const f = (placeR + thisR) / dist;
this.placeOffsets = {
x: dx * f - dx,
y: dy * f - dy
};
this.mergedMarkerData.x += this.placeOffsets.x;
this.mergedMarkerData.y += this.placeOffsets.y;
lodash.forEach(this.graphVertex.incidentEdges(), edge => {
lodash.forEach(edge.renderSegments, segment => {
segment.refreshRenderData(display);
});
});
}
}
}
getMarkerBBox() {
return this.markerBBox;
}
setFocused(focused) {
this.focused = focused;
}
isFocused() {
return this.focused === true;
}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
runFocusTransition(display, callback) {}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
setAllPatternsFocused() {}
getZIndex() {
return this.zIndex;
}
getAverageCoord() {
const dataArray = this.getRenderDataArray();
let xTotal = 0;
let yTotal = 0;
lodash.forEach(dataArray, data => {
xTotal += data.x;
yTotal += data.y;
});
return {
x: xTotal / dataArray.length,
y: yTotal / dataArray.length
};
}
hasRenderData() {
const dataArray = this.getRenderDataArray();
return dataArray && dataArray.length > 0;
}
/**
* Does not need to be implemented by subclass
*/
// eslint-disable-next-line @typescript-eslint/no-empty-function
makeDraggable(transitive) {}
toString() {
return `${this.getType()} point: ${this.getId()} (${this.getName()})`;
}
}
/**
* Place: a Point subclass representing a 'place' that can be rendered on the
* map. A place is a point *other* than a transit stop/station, e.g. a home/work
* location, a point of interest, etc.
*/
class Stop extends Point {
constructor(data) {
super(data);
if (data && data.stop_lat && data.stop_lon) {
const xy = sm.forward([data.stop_lon, data.stop_lat]);
this.worldX = xy[0];
this.worldY = xy[1];
}
this.patterns = [];
this.patternRenderData = {};
this.patternFocused = {};
this.patternCount = 0;
this.patternStylerKey = 'stops_pattern';
this.isSegmentEndPoint = false;
}
/**
* Get id
*/
getId() {
return this.stop_id;
}
/**
* Get type
*/
getType() {
return 'STOP';
}
/**
* Get name
*/
getName() {
if (!this.stop_name) return `Unnamed Stop (ID=${this.getId()})`;
return this.stop_name;
}
/**
* Get lat
*/
getLat() {
return this.stop_lat;
}
/**
* Get lon
*/
getLon() {
return this.stop_lon;
}
containsSegmentEndPoint() {
return this.isSegmentEndPoint;
}
containsBoardPoint() {
return this.isBoardPoint;
}
containsAlightPoint() {
return this.isAlightPoint;
}
containsTransferPoint() {
return this.isTransferPoint;
}
getPatterns() {
return this.patterns;
}
addPattern(pattern) {
if (this.patterns.indexOf(pattern) === -1) this.patterns.push(pattern);
}
/**
* Add render data
*
* @param {Object} stopInfo
*/
addRenderData(stopInfo) {
if (stopInfo.rEdge.getType() === 'TRANSIT') {
const s = {
getZIndex: function () {
if (this.owner.graphVertex) {
return this.owner.getZIndex();
}
return this.rEdge.getZIndex() + 1;
},
owner: this,
sortableType: 'POINT_STOP_PATTERN'
};
for (const key in stopInfo) s[key] = stopInfo[key];
const patternId = stopInfo.rEdge.patternIds;
this.patternRenderData[patternId] = s; // .push(s);
lodash.forEach(stopInfo.rEdge.patterns, pattern => {
this.addPattern(pattern);
});
}
this.patternCount = Object.keys(this.patternRenderData).length;
}
isPatternFocused(patternId) {
if (!(patternId in this.patternFocused)) return true;
return this.patternFocused[patternId];
}
setPatternFocused(patternId, focused) {
this.patternFocused[patternId] = focused;
}
setAllPatternsFocused(focused) {
for (const key in this.patternRenderData) {
this.patternFocused[key] = focused;
}
}
/**
* Draw a stop
*
* @param {Display} display
*/
render(display) {
super.render(display);
if (this.patternCount === 0) return;
this.initMarkerData(display);
const styler = display.styler; // For segment endpoints, draw the "merged" marker
if (this.isSegmentEndPoint && this.mergedMarkerData) {
display.drawRect({
x: this.mergedMarkerData.x,
y: this.mergedMarkerData.y
}, {
fill: styler.compute2('stops_merged', 'fill', this),
height: this.mergedMarkerData.height,
rx: this.mergedMarkerData.rx,
ry: this.mergedMarkerData.ry,
stroke: styler.compute2('stops_merged', 'stroke', this),
'stroke-width': styler.compute2('stops_merged', 'stroke-width', this),
width: this.mergedMarkerData.width
}); // store marker bounding box
this.markerBBox = {
height: this.mergedMarkerData.height,
width: this.mergedMarkerData.width,
x: this.mergedMarkerData.x,
y: this.mergedMarkerData.y
};
} // TODO: Restore inline stop
}
getRenderDataArray() {
const dataArray = [];
for (const patternId in this.patternRenderData) {
dataArray.push(this.patternRenderData[patternId]);
}
return dataArray;
}
clearRenderData() {
this.patternRenderData = {};
this.mergedMarkerData = null;
this.placeOffsets = {
x: 0,
y: 0
};
}
}
/**
* Place: a Point subclass representing a 'place' that can be rendered on the
* map. A place is a point *other* than a transit stop/station, e.g. a home/work
* location, a point of interest, etc.
*/
class Place extends Point {
/**
* the constructor
*/
constructor(data) {
super(data);
if (data && data.place_lat && data.place_lon) {
const xy = sm.forward([data.place_lon, data.place_lat]);
this.worldX = xy[0];
this.worldY = xy[1];
}
this.zIndex = 100000;
}
/**
* Get Type
*/
getType() {
return 'PLACE';
}
/**
* Get ID
*/
getId() {
return this.place_id;
}
/**
* Get Name
*/
getName() {
return this.place_name;
}
/**
* Get lat
*/
getLat() {
return this.place_lat;
}
/**
* Get lon
*/
getLon() {
return this.place_lon;
}
containsSegmentEndPoint() {
return true;
}
containsFromPoint() {
return this.getId() === 'from';
}
containsToPoint() {
return this.getId() === 'to';
}
addRenderData(pointInfo) {
this.renderData.push(pointInfo);
}
getRenderDataArray() {
return this.renderData;
}
clearRenderData() {
this.renderData = [];
}
/**
* Draw a place
*
* @param {Display} display
*/
render(display) {
super.render(display);
const styler = display.styler;
if (!this.renderData) return;
const displayStyle = styler.compute2('places', 'display', this);
if (displayStyle === 'none') return;
this.renderXY = {
x: display.xScale.compute(display.activeZoomFactors.useGeographicRendering ? this.worldX : this.graphVertex.x),
y: display.yScale.compute(display.activeZoomFactors.useGeographicRendering ? this.worldY : this.graphVertex.y)
};
const radius = styler.compute2('places', 'r', this) || 10;
display.drawCircle(this.renderXY, {
fill: styler.compute2('places', 'fill', this) || '#fff',
r: radius,
stroke: styler.compute2('places', 'stroke', this) || '#000',
'stroke-width': styler.compute2('places', 'stroke-width', this) || 2
});
this.markerBBox = {
height: radius * 2,
width: radius * 2,
x: this.renderXY.x - radius,
y: this.renderXY.y - radius
};
}
}
/**
* Utility class used when clustering points into MultiPoint objects
*/
class PointCluster {
constructor() {
this.points = [];
}
addPoint(point) {
if (this.points.indexOf(point) === -1) this.points.push(point);
}
mergeVertices(graph) {
const vertices = [];
lodash.forEach(this.points, point => {
vertices.push(point.graphVertex);
});
graph.mergeVertices(vertices);
}
}
/**
* MultiPoint: a Point subclass representing a collection of multiple points
* that have been merged into one for display purposes.
*/
class MultiPoint extends Point {
constructor(pointArray) {
super();
this.points = [];
if (pointArray) {
lodash.forEach(pointArray, point => {
this.addPoint(point);
});
}
this.renderData = [];
this.id = 'multi';
this.toPoint = this.fromPoint = null;
this.patternStylerKey = 'multipoints_pattern';
}
/**
* Get id
*/
getId() {
return this.id;
}
/**
* Get type
*/
getType() {
return 'MULTI';
}
getName() {
if (this.fromPoint) return this.fromPoint.getName();
if (this.toPoint) return this.toPoint.getName();
let shortest = null;
lodash.forEach(this.points, point => {
if (point.getType() === 'TURN') return;
if (!shortest || point.getName().length < shortest.length) {
shortest = point.getName();
}
});
return shortest;
}
containsSegmentEndPoint() {
for (let i = 0; i < this.points.length; i++) {
if (this.points[i].containsSegmentEndPoint()) return true;
}
return false;
}
containsBoardPoint() {
for (let i = 0; i < this.points.length; i++) {
if (this.points[i].containsBoardPoint()) return true;
}
return false;
}
containsAlightPoint() {
for (let i = 0; i < this.points.length; i++) {
if (this.points[i].containsAlightPoint()) return true;
}
return false;
}
containsTransferPoint() {
for (let i = 0; i < this.points.length; i++) {
if (this.points[i].containsTransferPoint()) return true;
}
return false;
}
containsFromPoint() {
return this.fromPoint !== null;
}
containsToPoint() {
return this.toPoint !== null;
}
getPatterns() {
const patterns = [];
lodash.forEach(this.points, point => {
if (!point.patterns) return;
lodash.forEach(point.patterns, pattern => {
if (patterns.indexOf(pattern) === -1) patterns.push(pattern);
});
});
return patterns;
}
addPoint(point) {
if (this.points.indexOf(point) !== -1) return;
this.points.push(point);
this.id += '-' + point.getId();
if (point.containsFromPoint()) {
// getType() === 'PLACE' && point.getId() === 'from') {
this.fromPoint = point;
}
if (point.containsToPoint()) {
// getType() === 'PLACE' && point.getId() === 'to') {
this.toPoint = point;
}
this.calcWorldCoords();
}
calcWorldCoords() {
let tx = 0;
let ty = 0;
lodash.forEach(this.points, point => {
tx += point.worldX;
ty += point.worldY;
});
this.worldX = tx / this.points.length;
this.worldY = ty / this.points.length;
}
/**
* Add render data
*
* @param {Object} stopInfo
*/
addRenderData(pointInfo) {
if (pointInfo.offsetX !== 0 || pointInfo.offsetY !== 0) {
this.hasOffsetPoints = true;
}
this.renderData.push(pointInfo);
}
clearRenderData() {
this.hasOffsetPoints = false;
this.renderData = [];
}
/**
* Draw a multipoint
*
* @param {Display} display
*/
render(display) {
super.render(display);
if (!this.renderData) return; // Compute the bounds of the merged marker
const xArr = this.renderData.map(d => d.x);
const yArr = this.renderData.map(d => d.y);
const xMin = Math.min(...xArr);
const xMax = Math.max(...xArr);
const yMin = Math.min(...yArr);
const yMax = Math.max(...yArr);
const r = 6;
const x = xMin - r;
const y = yMin - r;
const width = xMax - xMin + r * 2;
const height = yMax - yMin + r * 2; // Draw the merged marker
display.drawRect({
x,
y
}, {
fill: '#fff',
height,
rx: r,
ry: r,
stroke: '#000',
'stroke-width': 2,
width
}); // Store marker bounding box
this.markerBBox = {
height,
width,
x,
y
}; // TODO: support pattern-specific markers
}
initMergedMarker(display) {
// set up the merged marker
if (this.fromPoint || this.toPoint) {
this.mergedMarker = this.markerSvg.append('g').append('circle').datum({
owner: this
}).attr('class', 'transitive-multipoint-marker-merged');
} else if (this.hasOffsetPoints || this.renderData.length > 1) {
this.mergedMarker = this.markerSvg.append('g').append('rect').datum({
owner: this
}).attr('class', 'transitive-multipoint-marker-merged');
}
}
getRenderDataArray() {
return this.renderData;
}
setFocused(focused) {
this.focused = focused;
lodash.forEach(this.points, point => {
point.setFocused(focused);
});
}
runFocusTransition(display, callback) {
if (this.mergedMarker) {
const newStrokeColor = display.styler.compute(display.styler.multipoints_merged.stroke, display, {
owner: this
});
this.mergedMarker.transition().style('stroke', newStrokeColor).call(callback);
}
if (this.label) this.label.runFocusTransition(display, callback);
}
}
/**
* Utility class to cluster points into MultiPoint objects
*/
class PointClusterMap {
constructor(transitive) {
this.transitive = transitive;
this.clusters = [];
this.clusterLookup = {}; // maps Point object to its containing cluster
const pointArr = [];
lodash.forEach(Object.values(transitive.stops), point => {
if (point.used) pointArr.push(point);
}, this);
lodash.forEach(Object.values(transitive.turnPoints), turnPoint => {
pointArr.push(turnPoint);
}, this);
const links = d3.geom.voronoi().x(function (d) {
return d.worldX;
}).y(function (d) {
return d.worldY;
}).links(pointArr);
lodash.forEach(links, link => {
const dist = distance(link.source.worldX, link.source.worldY, link.target.worldX, link.target.worldY);
if (dist < 100 && (link.source.getType() !== 'TURN' || link.target.getType() !== 'TURN')) {
const sourceInCluster = (link.source in this.clusterLookup);
const targetInCluster = (link.target in this.clusterLookup);
if (sourceInCluster && !targetInCluster) {
this.addPointToCluster(link.target, this.clusterLookup[link.source]);
} else if (!sourceInCluster && targetInCluster) {
this.addPointToCluster(link.source, this.clusterLookup[link.target]);
} else if (!sourceInCluster && !targetInCluster) {
const cluster = new PointCluster();
this.clusters.push(cluster);
this.addPointToCluster(link.source, cluster);
this.addPointToCluster(link.target, cluster);
}
}
}, this);
this.vertexPoints = [];
lodash.forEach(this.clusters, cluster => {
const multipoint = new MultiPoint(cluster.points);
this.vertexPoints.push(multipoint);
lodash.forEach(cluster.points, point => {
point.multipoint = multipoint;
});
});
}
addPointToCluster(point, cluster) {
cluster.addPoint(point);
this.clusterLookup[point] = cluster;
}
clearMultiPoints() {
lodash.forEach(this.clusters, cluster => {
lodash.forEach(cluster.points, point => {
point.multipoint = null;
});
});
}
getVertexPoints(baseVertexPoints) {
if (!baseVertexPoints) return this.vertexPoints;
const vertexPoints = this.vertexPoints.concat();
lodash.forEach(baseVertexPoints, point => {
if (!point.multipoint) vertexPoints.push(point);
});
return vertexPoints;
}
}
let rEdgeId = 0;
/**
* RenderedEdge
*/
class RenderedEdge {
constructor(graphEdge, forward, type, useGeographicRendering) {
this.id = rEdgeId++;
this.graphEdge = graphEdge;
this.forward = forward;
this.type = type;
this.points = [];
this.clearOffsets();
this.focused = true;
this.sortableType = 'SEGMENT';
this.useGeographicRendering = useGeographicRendering;
}
clearGraphData() {
this.graphEdge = null;
this.edgeFromOffset = 0;
this.edgeToOffset = 0;
}
addPattern(pattern) {
if (!this.patterns) this.patterns = [];
if (this.patterns.indexOf(pattern) !== -1) return;
this.patterns.push(pattern); // generate the patternIds field
this.patternIds = constuctIdListString(this.patterns);
}
addPathSegment(pathSegment) {
if (!this.pathSegments) this.pathSegments = [];
if (this.pathSegments.indexOf(pathSegment) !== -1) return;
this.pathSegments.push(pathSegment); // generate the pathSegmentIds field
this.pathSegmentIds = constuctIdListString(this.pathSegments);
}
getId() {
return this.id;
}
getType() {
return this.type;
}
setFromOffset(offset) {
this.fromOffset = offset;
}
setToOffset(offset) {
this.toOffset = offset;
}
clearOffsets() {
this.fromOffset = 0;
this.toOffset = 0;
}
getAlignmentVector(alignmentId) {
if (this.graphEdge.getFromAlignmentId() === alignmentId) {
return this.graphEdge.fromVector;
}
if (this.graphEdge.getToAlignmentId() === alignmentId) {
return this.graphEdge.toVector;
}
return null;
}
offsetAlignment(alignmentId, offset) {
// If from/to alignment IDs match, set respective offset.
if (this.graphEdge.getFromAlignmentId() === alignmentId) {
this.setFromOffset(isOutwardVector(this.graphEdge.fromVector) ? offset : -offset);
}
if (this.graphEdge.getToAlignmentId() === alignmentId) {
this.setToOffset(isOutwardVector(this.graphEdge.toVector) ? offset : -offset);
}
}
setFocused(focused) {
this.focused = focused;
}
refreshRenderData(display) {
if (this.graphEdge.fromVertex.x === this.graphEdge.toVertex.x && this.graphEdge.fromVertex.y === this.graphEdge.toVertex.y) {
this.renderData = [];
return;
}
this.lineWidth = this.computeLineWidth(display, true);
const fromOffsetPx = this.fromOffset * this.lineWidth;
const toOffsetPx = this.toOffset * this.lineWidth;
if (this.useGeographicRendering && this.graphEdge.geomCoords) {
this.renderData = this.graphEdge.getGeometricCoords(fromOffsetPx, toOffsetPx, display, this.forward);
} else {
this.renderData = this.graphEdge.getRenderCoords(fromOffsetPx, toOffsetPx, display, this.forward);
}
const firstRenderPoint = this.renderData[0];
const lastRenderPoint = this.renderData[this.renderData.length - 1];
let pt;
if (!this.graphEdge.fromVertex.isInternal) {
pt = this.forward ? firstRenderPoint : lastRenderPoint;
if (pt) {
this.graphEdge.fromVertex.point.addRenderData({
rEdge: this,
x: pt.x,
y: pt.y
});
}
}
pt = this.forward ? lastRenderPoint : firstRenderPoint;
if (pt) {
this.graphEdge.toVertex.point.addRenderData({
rEdge: this,
x: pt.x,
y: pt.y
});
}
lodash.forEach(this.graphEdge.pointArray, (point, i) => {
if (point.getType() === 'TURN') return;
const t = (i + 1) / (this.graphEdge.pointArray.length + 1);
const coord = this.graphEdge.coordAlongEdge(this.forward ? t : 1 - t, this.renderData, display);
if (coord) {
point.addRenderData({
rEdge: this,
x: coord.x,
y: coord.y
});
}
});
}
computeLineWidth(display, includeEnvelope) {
const styler = display.styler;
if (styler && display) {
// compute the line width
const env = styler.compute(styler.segments.envelope, display, this);
if (env && includeEnvelope) {
return parseFloat(env.substring(0, env.length - 2), 10) - 2;
} else {
const lw = styler.compute(styler.segments['stroke-width'], display, this);
return parseFloat(lw.substring(0, lw.length - 2), 10) - 2;
}
}
}
isFocused() {
return this.focused === true;
}
getZIndex() {
return 10000;
}
/**
* Computes the point of intersection between two adjacent, offset RenderedEdges (the
* edge the function is called on and a second edge passed as a parameter)
* by "extending" the adjacent edges and finding the point of intersection. If
* such a point exists, the existing renderData arrays for the edges are
* adjusted accordingly, as are any associated stops.
*/
intersect(rEdge) {
// do no intersect adjacent edges of unequal bundle size
if (this.graphEdge.renderedEdges.length !== rEdge.graphEdge.renderedEdges.length) {
return;
}
const commonVertex = this.graphEdge.commonVertex(rEdge.graphEdge);
if (!commonVertex || commonVertex.point.isSegmentEndPoint) return;
const thisCheck = commonVertex === this.graphEdge.fromVertex && this.forward || commonVertex === this.graphEdge.toVertex && !this.forward;
const otherCheck = commonVertex === rEdge.graphEdge.fromVertex && rEdge.forward || commonVertex === rEdge.graphEdge.toVertex && !rEdge.forward;
const p1 = thisCheck ? this.renderData[0] : this.renderData[this.renderData.length - 1];
const v1 = this.graphEdge.getVector(commonVertex);
const p2 = otherCheck ? rEdge.renderData[0] : rEdge.renderData[rEdge.renderData.length - 1];
const v2 = rEdge.graphEdge.getVector(commonVertex);
if (!p1 || !p2 || !v1 || !v2 || p1.x === p2.x && p1.y === p2.y) return;
const isect = lineIntersection(p1.x, p1.y, p1.x + v1.x, p1.y - v1.y, p2.x, p2.y, p2.x + v2.x, p2.y - v2.y);
if (!isect.intersect) return; // adjust the endpoint of the first edge
if (thisCheck) {
this.renderData[0].x = isect.x;
this.renderData[0].y = isect.y;
} else {
this.renderData[this.renderData.length - 1].x = isect.x;
this.renderData[this.renderData.length - 1].y = isect.y;
} // adjust the endpoint of the second edge
if (otherCheck) {
rEdge.renderData[0].x = isect.x;
rEdge.renderData[0].y = isect.y;
} else {
rEdge.renderData[rEdge.renderData.length - 1].x = isect.x;
rEdge.renderData[rEdge.renderData.length - 1].y = isect.y;
} // update the point renderData
commonVertex.point.addRenderData({
rEdge: this,
x: isect.x,
y: isect.y
});
}
findExtension(vertex) {
const incidentEdges = vertex.incidentEdges(this.graphEdge);
const bundlerId = this.patternIds || this.pathSegmentIds;
for (let e = 0; e < incidentEdges.length; e++) {
const edgeSegments = incidentEdges[e].renderedEdges;
for (let s = 0; s < edgeSegments.length; s++) {
const segment = edgeSegments[s];
const otherId = segment.patternIds || segment.pathSegmentIds;
if (bundlerId === otherId) {
return segment;
}
}
}
}
toString() {
return `RenderedEdge ${this.id} type=${this.type} on ${this.graphEdge.toString()} w/ patterns ${this.patternIds} fwd=${this.forward}`;
}
}
/**
* Helper method to construct a merged ID string from a list of items with
* their own IDs
*/
function constuctIdListString(items) {
const idArr = [];
lodash.forEach(items, item => {
idArr.push(item.getId());
});
idArr.sort();
return idArr.join(',');
}
/**
* RenderedSegment
*/
let rSegmentId = 0;
class RenderedSegment {
constructor(pathSegment) {
this.id = rSegmentId++;
this.renderedEdges = [];
this.pathSegment = pathSegment;
if (pathSegment) this.type = pathSegment.type;
this.focused = true;
}
getId() {
return this.id;
}
getType() {
return this.type;
}
addRenderedEdge(rEdge) {
this.renderedEdges.push(rEdge);
}
render(display) {
const styler = display.styler;
this.renderData = [];
lodash.forEach(this.renderedEdges, rEdge => {
this.renderData = this.renderData.concat(rEdge.renderData);
}); // Check if this segment is to be drawn as an arc; if so replace render data
if (this.pathSegment.journeySegment && this.pathSegment.journeySegment.arc) {
const first = this.renderData[0];
const last = this.renderData[this.renderData.length - 1];
const v = {
x: last.x - first.x,
y: last.y - first.y
};
const vp = rotateVector(normalizeVector(v), -Math.PI / 2);
const dist = distance(first.x, first.y, last.x, last.y);
const arc = {
arc: -45,
ex: (last.x + first.x) / 2 + vp.x * (dist / 4),
ey: (last.y + first.y) / 2 + vp.y * (dist / 4),
radius: dist * 0.75,
x: last.x,
y: last.y
};
this.renderData = [first, arc, last];
}
display.drawPath(this.renderData, {
fill: 'none',
stroke: styler.compute2('segments', 'stroke', this),
'stroke-dasharray': styler.compute2('segments', 'stroke-dasharray', this),
'stroke-linecap': styler.compute2('segments', 'stroke-linecap', this),
'stroke-width': styler.compute2('segments', 'stroke-width', this)
});
}
setFocused(focused) {
this.focused = focused;
}
isFocused() {
return this.focused;
}
runFocusTransition(display, callback) {
const newColor = display.styler.compute(display.styler.segments.stroke, display, this);
this.lineGraph.transition().style('stroke', newColor).call(callback);
}
getZIndex() {
return this.zIndex;
}
computeLineWidth(display, includeEnvelope) {
const styler = display.styler;
if (styler && display) {
// compute the line width
const env = styler.compute(styler.segments.envelope, display, this);
if (env && includeEnvelope) {
return parseFloat(env.substring(0, env.length - 2), 10) - 2;
} else {
const lw = styler.compute(styler.segments['stroke-width'], display, this);
return parseFloat(lw.substring(0, lw.length - 2), 10) - 2;
}
}
}
compareTo(other) {
// show transit segments in front of other types
if (this.type === 'TRANSIT' && other.type !== 'TRANSIT') return -1;
if (other.type === 'TRANSIT' && this.type !== 'TRANSIT') return 1;
if (this.type === 'TRANSIT' && other.type === 'TRANSIT') {
// for two transit segments, try sorting transit mode first
if (this.mode && other.mode && this.mode !== other.mode) {
return this.mode > other.mode;
} // for two transit segments of the same mode, sort by id (for display consistency)
return this.getId() < other.getId();
}
}
getLabelTextArray() {
const textArray = [];
lodash.forEach(this.patterns, pattern => {
// TODO: Should we attempt to extract part of the long name if short name
// is missing?
const shortName = pattern.route.route_short_name;
if (textArray.indexOf(shortName) === -1) textArray.push(shortName);
});
return textArray;
}
getLabelAnchors(display, spacing) {
const labelAnchors = [];
this.computeRenderLength(display);
const anchorCount = Math.floor(this.renderLength / spacing);
const pctSpacing = spacing / this.renderLength;
for (let i = 0; i < anchorCount; i++) {
const t = i % 2 === 0 ? 0.5 + i / 2 * pctSpacing : 0.5 - (i + 1) / 2 * pctSpacing;
const coord = this.coordAlongRenderedPath(t, display);
if (coord) labelAnchors.push(coord);
}
return labelAnchors;
}
coordAlongRenderedPath(t, display) {
const loc = t * this.renderLength;
let cur = 0;
for (let i = 0; i < this.renderedEdges.length; i++) {
const rEdge = this.renderedEdges[i];
const edgeRenderLen = rEdge.graphEdge.getRenderLength(display);
if (loc <= cur + edgeRenderLen) {
const t2 = (loc - cur) / edgeRenderLen;
return rEdge.graphEdge.coordAlongEdge(t2, rEdge.renderData, display);
}
cur += edgeRenderLen;
}
}
computeRenderLength(display) {
this.renderLength = 0;
lodash.forEach(this.renderedEdges, rEdge => {
this.renderLength += rEdge.graphEdge.getRenderLength(display);
});
}
toString() {
return `RenderedSegment ${this.id} on ${this.pathSegment ? this.pathSegment.toString() : ' (null segment)'}`;
}
}
/**
* Edge
*/
let edgeId = 0;
class Edge {
/**
* Initialize a new edge
* @constructor
* @param {Point[]} pointArray - the internal Points for this Edge
* @param {Vertex} fromVertex
* @param {Vertex} toVertex
*/
constructor(pointArray, fromVertex, toVertex) {
this.id = edgeId++;
this.pointArray = pointArray;
this.fromVertex = fromVertex;
this.toVertex = toVertex;
this.pathSegments = [];
this.renderedEdges = [];
}
getId() {
return this.id;
}
/**
*
*/
getLength() {
const dx = this.toVertex.x - this.fromVertex.x;
const dy = this.toVertex.y - this.fromVertex.y;
return Math.sqrt(dx * dx + dy * dy);
}
getWorldLength() {
if (!this.worldLength) this.calculateWorldLengthAndMidpoint();
return this.worldLength;
}
getWorldMidpoint() {
if (!this.worldMidpoint) this.calculateWorldLengthAndMidpoint();
return this.worldMidpoint;
}
calculateWorldLengthAndMidpoint() {
const allPoints = [this.fromVertex.point].concat(this.pointArray, [this.toVertex.point]);
this.worldLength = 0;
for (var i = 0; i < allPoints.length - 1; i++) {
this.worldLength += distance(allPoints[i].worldX, allPoints[i].worldY, allPoints[i + 1].worldX, allPoints[i + 1].worldY);
}
if (this.worldLength === 0) {
this.worldMidpoint = {
x: this.fromVertex.point.worldX,
y: this.fromVertex.point.worldY
};
} else {
let distTraversed = 0;
for (i = 0; i < allPoints.length - 1; i++) {
const dist = distance(allPoints[i].worldX, allPoints[i].worldY, allPoints[i + 1].worldX, allPoints[i + 1].worldY);
if ((distTraversed + dist) / this.worldLength >= 0.5) {
// find the position along this segment (0 <= t <= 1) where the edge midpoint lies
const t = (0.5 - distTraversed / this.worldLength) / (dist / this.worldLength);
this.worldMidpoint = {
x: allPoints[i].worldX + t * (allPoints[i + 1].worldX - allPoints[i].worldX),
y: allPoints[i].worldY + t * (allPoints[i + 1].worldY - allPoints[i].worldY)
};
this.pointsBeforeMidpoint = i;
this.pointsAfterMidpoint = this.pointArray.length - i;
break;
}
distTraversed += dist;
}
}
}
/**
*
*/
isAxial() {
return this.toVertex.x === this.fromVertex.x || this.toVertex.y === this.fromVertex.y;
}
/**
*
*/
hasCurvature() {
return this.elbow !== null;
}
/**
*
*/
replaceVertex(oldVertex, newVertex) {
if (oldVertex === this.fromVertex) this.fromVertex = newVertex;
if (oldVertex === this.toVertex) this.toVertex = newVertex;
}
/**
* Add a path segment that traverses this edge
*/
addPathSegment(segment) {
this.pathSegments.push(segment);
}
copyPathSegments(baseEdge) {
lodash.forEach(baseEdge.pathSegments, pathSegment => {
this.addPathSegment(pathSegment);
});
}
getPathSegmentIds(baseEdge) {
const pathSegIds = this.pathSegments.map(segment => segment.id);
pathSegIds.sort();
return pathSegIds.join(',');
}
/**
*
*/
addRenderedEdge(rEdge) {
if (this.renderedEdges.indexOf(rEdge) !== -1) return;
this.renderedEdges.push(rEdge);
}
/** internal geometry functions **/
calculateGeometry(cellSize, angleConstraint) {
// if(!this.hasTransit()) angleConstraint = 5;
angleConstraint = angleConstraint || 45;
this.angleConstraintR = angleConstraint * Math.PI / 180;
this.fx = this.fromVertex.point.worldX;
this.fy = this.fromVertex.point.worldY;
this.tx = this.toVertex.point.worldX;
this.ty = this.toVertex.point.worldY;
const midpoint = this.getWorldMidpoint();
const targetFromAngle = getVectorAngle(midpoint.x - this.fx, midpoint.y - this.fy);
this.constrainedFromAngle = Math.round(targetFromAngle / this.angleConstraintR) * this.angleConstraintR;
const fromAngleDelta = Math.abs(this.constrainedFromAngle - targetFromAngle);
this.fvx = Math.cos(this.constrainedFromAngle);
this.fvy = Math.sin(this.constrainedFromAngle);
const targetToAngle = getVectorAngle(midpoint.x - this.tx, midpoint.y - this.ty);
this.constrainedToAngle = Math.round(targetToAngle / this.angleConstraintR) * this.angleConstraintR;
const toAngleDelta = Math.abs(this.constrainedToAngle - targetToAngle);
this.tvx = Math.cos(this.constrainedToAngle);
this.tvy = Math.sin(this.constrainedToAngle);
const tol = 0.01;
const v = normalizeVector({
x: this.toVertex.x - this.fromVertex.x,
y: this.toVertex.y - this.fromVertex.y
}); // check if we need to add curvature
if (!equalVectors(this.fvx, this.fvy, -this.tvx, -this.tvy, tol) || !equalVectors(this.fvx, this.fvy, v.x