UNPKG

transitive-js

Version:

A tool for generating dynamic stylized transit maps that are easy to understand.

1,995 lines (1,646 loc) 186 kB
import d3 from 'd3'; import Emitter from 'component-emitter'; import { forEach } from 'lodash-es'; import SphericalMercator from 'sphericalmercator'; import PriorityQueue from 'priorityqueuejs'; import SVG from 'svg.js'; import roundedRect from 'rounded-rect'; import measureText from '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; forEach(this.graphVertex.incidentEdges(), edge => { 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; 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); 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 = []; 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) { 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; 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 = []; forEach(this.points, point => { if (!point.patterns) return; 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; 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; 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 = []; forEach(Object.values(transitive.stops), point => { if (point.used) pointArr.push(point); }, this); 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); 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 = []; forEach(this.clusters, cluster => { const multipoint = new MultiPoint(cluster.points); this.vertexPoints.push(multipoint); forEach(cluster.points, point => { point.multipoint = multipoint; }); }); } addPointToCluster(point, cluster) { cluster.addPoint(point); this.clusterLookup[point] = cluster; } clearMultiPoints() { forEach(this.clusters, cluster => { forEach(cluster.points, point => { point.multipoint = null; }); }); } getVertexPoints(baseVertexPoints) { if (!baseVertexPoints) return this.vertexPoints; const vertexPoints = this.vertexPoints.concat(); 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 }); } 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 = []; 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 = []; 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 = []; 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; 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) { 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, v.y, tol)) { // see if the default endpoint angles produce a valid intersection const isect = this.computeEndpointIntersection(); if (isect.intersect) { // if so, compute the elbow and we're done this.elbow = { x: this.fx + isect.u * this.fvx, y: this.fy + isect.u * this.fvy }; } else { // if not, adjust the two endpoint angles until they properly intersect // default test: compare angle adjustments (if significant