lib/transitive.ts
/* eslint-disable camelcase */ // FIXME remove camel case
/* globals Display */
import d3 from 'd3'
import Emitter from 'component-emitter'
import Network from './core/network'
import SvgDisplay from './display/svg-display'
import CanvasDisplay from './display/canvas-display'
import DefaultRenderer from './renderer/default-renderer'
import WireframeRenderer from './renderer/wireframe-renderer'
import Styler from './styler/styler'
import Labeler from './labeler/labeler'
import Point from './point/point'
import { sm } from './util'
// Transitive Data Model
/**
* A description of the transit pattern that a segment of a journey is using.
*
* TODO: move to core/journey.js when adding static typing to that file
*/
type SegmentPattern = {
/**
* The from stop index within the pattern referenced by the pattern_id
*/
from_stop_index: number
/**
* The ID of the pattern
*/
pattern_id: string
/**
* The to stop index within the pattern referenced by the pattern_id
*/
to_stop_index: number
}
/**
* A point where a segment starts or ends. This must be either a place or a stop
* and must have a proper reference to a defined place or stop that is defined
* in the list of places or stops in the root transitive data object.
*
* TODO: move to core/journey.js when adding static typing to that file
*/
type SegmentPoint = {
/**
* The place_id of this point if it is a place. This MUST be set if the "type"
* value is "PLACE"
*/
place_id?: string
/**
* The place_id of this point if it is a place. This MUST be set if the "type"
* value is "STOP"
*/
stop_id?: string
/**
* The type of place that this is.
*/
type: 'PLACE' | 'STOP'
}
/**
* Information about a particular segment of a journey.
*
* TODO: move to core/journey.js when adding static typing to that file
*/
type Segment = {
/**
* If set to true, instructs the renderer to ignore all other geometry data
* and instead draw an arc between the from and to points.
*/
arc?: boolean
/**
* The starting point of this segment
*/
from: SegmentPoint
/**
* A list of pattern segments identifying how this segment uses certain parts
* of the transit network. This should be set when the type of this segment is
* "TRANSIT".
*/
patterns?: SegmentPattern[]
/**
* A list of strings representing street edge IDs that this journey uses. This
* should be set when the type of this segment is not "TRANSIT".
*/
streetEdges?: string[]
/**
* The ending point of this segment
*/
to: SegmentPoint
/**
* The type of segment. This should be set to "TRANSIT" for transit segments
* or the on-street leg mode otherwise.
*/
type: string
}
/**
* An object describing how a journey uses various parts of the transportation
* network.
*
* This object can additionally have other key/value items added onto the data
* model that may or may not be processed with custom styler rules. However,
* a few keys might be overwritten by transitive internals, so choose carefully.
*
* TODO: move to core/journey.js when adding static typing to that file
*/
type Journey = {
/**
* The ID of the journey
*/
journey_id: string
/**
* The name of the journey
*/
journey_name: string
/**
* The segments of a journey
*/
segments: Segment[]
}
/**
* Information about a sequence of stops that make up a directional segment of
* travel that a transit trip or part of a transit trip takes.
*
* This object can additionally have other key/value items added onto the data
* model that may or may not be processed with custom styler rules. However,
* a few keys might be overwritten by transitive internals, so choose carefully.
*
* TODO: move to core/pattern.js when adding static typing to that file
*/
type Pattern = {
/**
* The ID of the pattern
*/
pattern_id: string
/**
* The name of the pattern
*/
pattern_name: string
/**
* If true, unconditionally render the entire pattern.
*/
render?: boolean
/**
* The ID of the transit route associated with this pattern
*/
route_id: string
/**
* A list of stops in order of the direction of travel and the associated
* geometry to that particular stop. The first stop omits the geometry, but
* all further stops should include the geometry. It is possible to include
* intermediate stops within the pattern or just the final stop.
*/
stops: Array<{
/**
* An encoded polyline string representing the path that the transit trip
* took from the previous stop in this list to this current stop. This is
* omitted for the first stop, but should be included for all further stops.
*/
geometry?: string
/**
* The ID of the stop
*/
stop_id: string
}>
}
/**
* A place is a point *other* than a transit stop/station, e.g. a home/work
* location, a point of interest, etc.
*
* This object can additionally have other key/value items added onto the data
* model that may or may not be processed with custom styler rules. However,
* a few keys might be overwritten by transitive internals, so choose carefully.
*
* TODO: move to point/place.js when adding static typing to that file
*/
type Place = {
/**
* The ID of the place
*/
place_id: string
/**
* The latitude of the place
*/
place_lat: number
/**
* The longitude of the place
*/
place_lon: number
/**
* The name of the place
*/
place_name: string
}
/**
* Information about a particular transit route.
*
* This object can additionally have other key/value items added onto the data
* model that may or may not be processed with custom styler rules. However,
* a few keys might be overwritten by transitive internals, so choose carefully.
*
* TODO: move to core/route.js when adding static typing to that file
*/
type Route = {
/**
* A string describing the route color in hex color format. If this value is
* provided and the first character is not a '#' character, that character
* will be added to the front of the string.
*/
route_color?: string
/**
* The route's ID
*/
route_id: string
/**
* The short name of the route
*/
route_short_name?: string
/**
* The GTFS route type number.
*/
route_type: number
}
/**
* A transit stop.
*
* This object can additionally have other key/value items added onto the data
* model that may or may not be processed with custom styler rules. However,
* a few keys might be overwritten by transitive internals, so choose carefully.
*
* TODO: move to point/stop.js when adding static typing to that file
*/
type Stop = {
/**
* The ID of the stop
*/
stop_id: string
/**
* The latitude of the stop
*/
stop_lat: number
/**
* The longitude of the stop
*/
stop_lon: number
/**
* The name of the stop
*/
stop_name?: string
}
/**
* Edges describing a section of the street network.
*
* TODO: move to core/network.js when adding static typing to that file
*/
type StreetEdge = {
/**
* A unique edge ID
*/
edge_id: number | string
/**
* A geometry object, typically from the leg response in an OpenTripPlanner
* itinerary
*/
geometry: {
/**
* An encoded polyline string
*/
points: string
}
}
/**
* An object with various pieces of data that should be rendered.
*/
type TransitiveData = {
/**
* A list of journeys that describing how the transit network is used in
* specific journeys (typically OTP itineraries).
*/
journeys?: Journey[]
/**
* A list of transit trips that should be rendered or are referenced by
* individual journeys
*/
patterns: Pattern[]
/**
* A list of places in the transportation network
*/
places: Place[]
/**
* A list of transit routes in the transportation network
*/
routes: Route[]
/**
* A list of transit stops in the transportation network
*/
stops: Stop[]
/**
* A list of street edges in the transportation network that are referenced by
* individual journeys.
*/
streetEdges: StreetEdge[]
}
// Transitive Style data model
/**
* A function for calculating the style of a particular feature.
*/
type TransitiveStyleComputeFn = (
/**
* The transtiive display instance currently being used.
*/
display?: CanvasDisplay | SvgDisplay,
/**
* The entity instance which the style result will be applied to.
*
* FIXME add better typing
*/
entity?: unknown,
/**
* The index of the entity within some collection. This argument's value may
* not always be included when calling this function.
*/
index?: number,
/**
* Some util functions for calculating certain styles.
* See code: https://github.com/conveyal/transitive.js/blob/6a8932930de003788b9034609697421731e7f812/lib/styler/styles.js#L17-L44
* FIXME add better typing once other files are typed
*/
styleUtils?: {
/**
* Creates a svg definition for a marker and returns the string url for the
* defined marker
*/
defineSegmentCircleMarker: (
/**
* The display being used to render transitive
*/
display: unknown,
/**
* The segment to create a marker for
*/
segment: unknown,
/**
* The size of the radius that the marker should have
*/
radius: number,
/**
* The fill color of the marker
*/
fillColor: string
) => string
/**
* Calculates the font size based on the display's scale
*/
fontSize: (display: unknown) => number
/**
* Calculates something as it relates to the zoom in relation to zoom
*/
pixels: (
zoom: unknown,
min: unknown,
normal: unknown,
max: unknown
) => unknown
/**
* Calculates a stroke width depending on the display scale
*/
strokeWidth: (display: unknown) => unknown
}
) => number | string
/**
* A map describing a styling override applied to a particular styling value
* noted with the key value. The value for this style can be either a number,
* string or result of a custom function.
*
* The applicability of each key differs between different styling entities.
* Example values for the key value include:
* - "background" - // background of text
* - "border-color" - // used for borders around text
* - "border-radius" - // used for borders around text
* - "border-width" - // used for borders around text
* - "color" - // text color
* - "display" - // whether or not to display the entity. Set value or make
* function return "none" to not display an entity.
* - "envelope" - // used in calculating line width
* - "fill" - // fill color
* - "font-family" - // font family used when rendering text
* - "font-size" - // font size used when rendering text
* - "marker-padding" - // Amount of padding to give a marker beyond its radius
* - "marker-type" - // for styling stops and maybe places. Valid values
* include: "circle", "rectangle" or "roundedrect"
* - "orientations" - // a list of possible orientations to try to apply to
* labels. Valid values include: "N", "S", "E", "W", "NE",
* "NW", "SE", "SW"
* - "r" - // a radius in pixels to apply to certain stops or places
* - "stroke" - // stroke color
* - "stroke-dasharray" - // stroke dasharray
* - "stroke-linecap" - // stroke linecap
* - "stroke-width" - // stroke width
*/
type TransitiveStyleConfig = Record<
string,
number | string | TransitiveStyleComputeFn
>
/**
* A map of transitive features and the associated map of config records that
* override transitive default style calculations.
*/
type TransitiveStyles = {
labels?: TransitiveStyleConfig
multipoints_merged?: TransitiveStyleConfig
multipoints_pattern?: TransitiveStyleConfig
places?: TransitiveStyleConfig
places_icon?: TransitiveStyleConfig
segment_label_containers?: TransitiveStyleConfig
segment_labels?: TransitiveStyleConfig
segments?: TransitiveStyleConfig
segments_front?: TransitiveStyleConfig
segments_halo?: TransitiveStyleConfig
stops_merged?: TransitiveStyleConfig
stops_pattern?: TransitiveStyleConfig
wireframe_edges?: TransitiveStyleConfig
wireframe_vertices?: TransitiveStyleConfig
}
type Bounds = [
[
/**
* west
*/
number,
/**
* south
*/
number
],
[
/**
* east
*/
number,
/**
* north
*/
number
]
]
type RendererType = 'wireframe' | 'default'
/**
* An object describing various styling actions that may be taken at a
* particular zoom level between the value of the key "minScale" and whatever
* value "minScale" is in the next ZoomFactor object in the zoomFactors config
* of the TransitiveOptions.
*/
type ZoomFactor = {
/**
* The minimum angle degree to use to render curves at this current zoom
* level.
*/
angleConstraint: number
/**
* The grid cell size to use for snapping purposes at this current zoom level.
*/
gridCellSize: number
/**
* A factor used to determine how many vertices should be displayed at this
* current zoom level.
*/
internalVertexFactor: number
/**
* If above 0, this will result in a point cluster map being used.
*/
mergeVertexThreshold: number
/**
* The minimum scale at which to show this zoom factor
*/
minScale: number
/**
* Whether or not to use geographic rendering
*/
useGeographicRendering?: boolean
}
type TransitiveOptions = {
/**
* whether the display should listen for window resize events and update
* automatically (defaults to true)
*/
autoResize?: boolean
/**
* An optional HTMLCanvasElement to render the Transitve display to
*/
canvas?: HTMLCanvasElement
/**
* Transitive Data to Render
*/
data: TransitiveData
/**
* Set to 'canvas' to use CanvasDisplay. Otherwise SvgDisplay is used.
*/
display?: string
/**
* padding to apply to the initial rendered network within the display.
* Expressed in pixels for top/bottom/left/right
*/
displayMargins?: {
bottom: number
left: number
right: number
top: number
}
/**
* a list of network element types to enable dragging for
*/
draggableTypes?: Array<string>
/**
* An optional HTMLElement to render the Transitve display to
*/
el?: HTMLElement
/**
* resolution of the grid in SphericalMercator meters
*/
gridCellSize?: number
/**
* whether to consider edges with the same origin/destination equivalent for
* rendering, even if intermediate stop sequence is different (defaults to
* true)
*/
groupEdges?: boolean
/**
* initial lon/lat bounds for the display
*/
initialBounds?: Bounds
/**
* Whether to render as a wireframe or default.
*/
initialRenderer?: RendererType
/**
* A list of transit mode types that should have labels created on them
*/
labeledModes?: number[]
/**
* Custom styling rules that affect rendering behavior
*/
styles: TransitiveStyles
/**
* Whether to enable the display's built-in zoom/pan functionality (defaults
* to true)
*/
zoomEnabled?: boolean
/**
* A list of different styling configurations to show at various zoom levels.
* This list of Zoomfactors must be ordered by each object's "minScale" key
* with the lowest "minScale" value appearing first and the largest one
* appearing last. There must be a "minScale" value below 1 in the list of
* definitions.
*
* Default values for this config item are used, unless overridden by adding
* this key and value. The default values can be found here:
* https://github.com/conveyal/transitive.js/blob/6a8932930de003788b9034609697421731e7f812/lib/display/display.js#L78-L92
*/
zoomFactors?: ZoomFactor[]
}
/**
* No clue what this global Display thing is. :(
*/
declare class Display {
constructor(arg0: Transitive)
}
/**
* A transformation {x, y, k} to the *initial* state of the map., where
* (x, y) is the pixel offset and k is a scale factor relative to an initial
* zoom level of 1.0. Intended primarily to support D3-style panning/zooming.
*/
type DisplayTransform = {
k: number
x: number
y: number
}
// FIXME add better typing once more files are typed
type Journeys = { [id: string]: { path: Record<string, unknown> } }
export default class Transitive {
data: TransitiveData | null | undefined
display!: CanvasDisplay | SvgDisplay
el?: HTMLElement
// FIXME: somehow typing the emit method (which is injected at the end of this
// file by component-emitter) causes the emit method to not work.
// emit!: (message: string, transitiveInstance: this, el?: HTMLElement) => void
labeler!: Labeler
options?: TransitiveOptions
network: Network | null | undefined
renderer!: DefaultRenderer | WireframeRenderer
styler!: Styler
constructor(options: TransitiveOptions) {
if (!(this instanceof Transitive)) return new Transitive(options)
this.options = options
if (this.options.zoomEnabled === undefined) this.options.zoomEnabled = true
if (this.options.autoResize === undefined) this.options.autoResize = true
if (this.options.groupEdges === undefined) this.options.groupEdges = true
if (options.el) this.el = options.el
this.display =
this.options.display === 'canvas'
? new CanvasDisplay(this)
: new SvgDisplay(this)
this.data = options.data
this.setRenderer(this.options.initialRenderer || 'default')
this.labeler = new Labeler(this)
this.styler = new Styler(options.styles, this)
if (options.initialBounds) {
this.display.fitToWorldBounds([
sm.forward(options.initialBounds[0]),
sm.forward(options.initialBounds[1])
])
}
}
/**
* Clear the Network data and redraw the (empty) map
*/
clearData() {
this.network = this.data = null
this.labeler.clear()
// @ts-expect-error See notes in constructor about emit method.
this.emit('clear data', this)
}
/**
* Update the Network data and redraw the map
*/
updateData(data: TransitiveData, resetDisplay?: boolean) {
this.network = null
this.data = data
if (resetDisplay) this.display.reset()
else if (this.data) this.display.scaleSet = false
this.labeler.clear()
// @ts-expect-error See notes in constructor about emit method.
this.emit('update data', this)
}
/**
* Return the collection of default segment styles for a mode.
*
* @param {String} an OTP mode string
*/
getModeStyles(mode: string) {
return this.styler.getModeStyles(mode, this.display || new Display(this))
}
/** Display/Render Methods **/
/**
* Set the DOM element that serves as the main map canvas
*/
setElement(el?: HTMLElement) {
if (this.el) d3.select(this.el).selectAll('*').remove()
this.el = el
// @ts-expect-error See notes in constructor about emit method.
this.emit('set element', this, this.el)
return this
}
/**
* Set the DOM element that serves as the main map canvas
*/
setRenderer(type: RendererType) {
switch (type) {
case 'wireframe':
this.renderer = new WireframeRenderer(this)
break
case 'default':
this.renderer = new DefaultRenderer(this)
break
}
}
/**
* Render
*/
render() {
if (!this.network) {
this.network = new Network(this, this.data)
}
if (!this.display.scaleSet) {
this.display.fitToWorldBounds(this.network.graph.bounds())
}
this.renderer.render()
// @ts-expect-error See notes in constructor about emit method.
this.emit('render', this)
}
/**
* Render to
*
* @param {Element} el
*/
renderTo(el: HTMLElement) {
this.setElement(el)
this.render()
// @ts-expect-error See notes in constructor about emit method.
this.emit('render to', this)
return this
}
/**
* focusJourney
*/
focusJourney(journeyId: string) {
if (!this.network) {
console.warn('Transitive network is not defined! Cannot focus journey!')
return
}
const journey = (this.network.journeys as Journeys)[journeyId]
const path = journey?.path || null
this.renderer.focusPath(path)
}
/**
* Sets the Display bounds
* @param {Array} lon/lat bounds expressed as [[west, south], [east, north]]
*/
setDisplayBounds(llBounds: Bounds) {
if (!this.display) return
const smWestSouth = sm.forward(llBounds[0])
const smEastNorth = sm.forward(llBounds[1])
// reset the display to make sure the correct display scale is recomputed
// see https://github.com/conveyal/transitive.js/pull/50
// FIXME this is a work-around for some other problem that occurs somewhere
// else. To investigate further, uncomment this line and compare the
// differences near the Putnam Plaza place in the Putnam Bug story.
this.display.reset()
this.display.setXDomain([smWestSouth[0], smEastNorth[0]])
this.display.setYDomain([smWestSouth[1], smEastNorth[1]])
this.display.computeScale()
}
/**
* Gets the Network bounds
* @returns {Array} lon/lat bounds expressed as [[west, south], [east, north]]
*/
getNetworkBounds() {
if (!this.network || !this.network.graph) return null
const graphBounds = this.network.graph.bounds()
const ll1 = sm.inverse(graphBounds[0])
const ll2 = sm.inverse(graphBounds[1])
return [
[Math.min(ll1[0], ll2[0]), Math.min(ll1[1], ll2[1])],
[Math.max(ll1[0], ll2[0]), Math.max(ll1[1], ll2[1])]
]
}
/**
* resize
*/
resize(width: number, height: number) {
if (!this.display) return
// @ts-expect-error I have no idea what magic this display object uses to
// get an el property. - Evan :(
d3.select(this.display.el)
.style('width', width + 'px')
.style('height', height + 'px')
// @ts-expect-error I have no idea what magic this display object uses to
// get a resized method. - Evan :(
this.display.resized()
}
/**
* trigger a display resize action (for externally-managed SVG containers)
*/
resized(width: number, height: number) {
// @ts-expect-error I have no idea what magic this display object uses to
// get a resized method. - Evan :(
this.display.resized(width, height)
}
setTransform(transform: DisplayTransform) {
this.display.applyTransform(transform)
this.render()
}
/** editor functions **/
createVertex(wx?: number, wy?: number) {
if (!this.network) {
console.warn('Transitive network is not defined! Cannot create vertex!')
return
}
this.network.graph.addVertex(new Point(), wx, wy)
}
}
/**
* Mixin `Emitter`
*/
Emitter(Transitive.prototype)