The Story of a Trip Transit agencies across the US are working hard to stem ridership losses and be more competitive against driving, bicycling, and ride hailing. Many agencies are adding additional service to core routes or rethinking the way their system is laid out. LYNX, the transit agency serving the Orlando, Florida metropolitan region, is one of these agencies. As part of a recent study on SR 436—one of its busiest corridors—LYNX considered a range of alternatives to increase the frequency and quality of transit service. Through the use of open-source data formats and software, the LYNX team was able to simulate the impact of its proposed alternatives on a trip-by-trip basis.
SR 436 Corridor Overview The focus of this study is the segment of SR 436 between SR 434 in Altamonte Springs and Orlando International Airport’s South Terminal. SR 436 is a state-operated arterial that serves regional and local travel, and is also the “gateway” into Central Florida for many of our 60 million annual visitors Origins and Destinations This analysis looked at trips that started or ended within one mile of the SR 436 study corridor
viewof toggle2 = checkbox({ description: "Click to toggle between the origins and destinations", options: [{ value: "destinations", label: "Show Destinations" }], value: "destinations" })
heatmap = { let container = DOM.element('div', { style: `width:${width}px;height:${width/1.6}px` }); yield container; let map = L.map(container, { scrollWheelZoom: false }).setView([28.5426548, -81.341934], 11); let osmLayer = L.tileLayer('https://maps.wikimedia.org/osm-intl/{z}/{x}/{y}@2x.png', { attribution: 'Wikimedia maps beta | © <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' }).addTo(map); // Process data into the [latitude, longitude, intensity] form that // Leaflet.heat desires. let tripPoints = selectedLocations.features.map(feature => feature.geometry.coordinates.slice().reverse().concat([0.8])); let tripLayer = heatLayer(tripPoints).addTo(map); encodedShapes.forEach(shape => { let polyline = L.polyline(polyUtil.decode(shape.attributes['encoded-polyline']), {color: '#C6017E'}).addTo(map); }); }
Level One: Aggregate Results Our top level of aggregation derived aggregate results—such as median travel time or median wait time—and created simple comparisons between the baseline and project scenarios Total Travel Times: Baseline vs Alternative For trips that would take the proposed alternative, the median travel time drops from one hour and 29 minutes (1:29) to one hour and 12 minutes (1:12).
viewof boxPlot1 = { const svg = d3.select(DOM.svg(width, height)); const margin = ({top: 20, right: 40, bottom: 30, left: 40}); const chartWidth = 120 - margin.left - margin.right; const chart = d3.box() .whiskers(iqr(1.5)) .tickFormat(formatMinutes) .width(chartWidth) .height(height - margin.top - margin.bottom); const chartOffset = (width - margin.left - margin.right) / 2; const [min, max] = d3.extent(beforeTimes.concat(afterTimes)); chart.domain([min, max]); svg.selectAll("g") .data([beforeTimes, afterTimes]) .enter().append("g") .attr("class", (d, i) => { if (i === 0) { return "box base-color"; } else { return "box alt-color"; } }) .attr("transform", function(d, i) { let x = i * chartOffset + margin.left; let y = margin.top; return `translate(${x},${y})`; }) .call(chart); // Returns a function to compute the interquartile range. function iqr(k) { return function(d, i) { var q1 = d.quartiles[0], q3 = d.quartiles[2], iqr = (q3 - q1) * k, i = -1, j = d.length; while (d[++i] < q1 - iqr); while (d[--j] > q3 + iqr); return [i, j]; }; } return svg.node(); }
Travel times are shown as hours and minutes in the format hh:mm. Min, max and median times are labeled on the right side of each box and whisker plot. Upper (Q3) and lower (Q1) quartiles are labeled on the left side, and comprise the innerquartile range (IQR). Outliers are indicated by circles, and indicate values that fall below Q1 − 1.5 IQR or above Q3 + 1.5 IQR.
Level Two: All Trips The second level of aggregation compared total results from each trip. For example, the total travel time and total distance traveled were compared between the baseline and project scenarios. Travel Time and Distance Toggling the checkbox on and off shows the shift toward lower travel times for trips that would take the alternative
viewof toggle = checkbox({ description: "Click to compare the <span class='base-color'>baseline</span> with the project <span class='alt-color'>alternative</span>", options: [{ value: "alt", label: "Show With Alternative" }], value: "alt" })
scatterplot = { const times = filteredTripEndpoints.reduce((r, e) => r.push(e.values[0].value.time, e.values[1].value.time) && r, []); // const xDomain = [0, d3.extent(times)[1]]; // There's one outlier at > 6 hours; setting domain manually to exclude const xDomain = [0, 5 * 60]; const distances = filteredTripEndpoints.reduce((r, e) => r.push(e.values[0].value.distance, e.values[1].value.distance) && r, []); const yDomain = [0, d3.extent(distances)[1]]; const margin = ({top: 20, right: 40, bottom: 30, left: 40}); const x = d3.scaleLinear() .domain(xDomain) .range([margin.left, width - margin.right]) .clamp(false) .nice(); const y = d3.scaleLinear() .domain(yDomain) .range([height - margin.bottom, margin.top]) .clamp(false) .nice(); const xAxis = g => g .attr("transform", `translate(0,${height - margin.bottom})`) .call(d3.axisBottom(x).ticks(5).tickFormat(d => d === 0 ? '' : moment.duration(d, 'minutes').humanize())) .call(g => g.select(".domain").remove()); const yAxis = g => g .attr("transform", `translate(${margin.left},0)`) .call(d3.axisLeft(y).ticks(height / 80).tickFormat(d => d + " mi")) .call(g => g.select(".domain").remove()); const line = d3.line() .x(d => x(d.time)) .y(d => y(d.distance)); const svg = d3.select(DOM.svg(width, height)); const gx = svg.append("g") .call(xAxis); const gy = svg.append("g") .call(yAxis); const c = d => d === "BASE" ? baseColor : altColor; const lines = svg.append("g") .selectAll("g") .data(deltas) .enter().append("g") .append("line") .attr("fill", "none") .attr("stroke-width", 1.0) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .attr("stroke", "#dddddd") .attr("x1", d => x(d.beforeTime)) .attr("x2", d => x(d.afterTime)) .attr("y1", d => y(d.beforeDistance)) .attr("y2", d => y(d.afterDistance)); let tripEndpoint = svg.append("g") .style("font", "bold 10px sans-serif") .selectAll("g") .data(filteredTripEndpointsBase, d => d.tripId); tripEndpoint.exit().remove(); tripEndpoint = tripEndpoint .enter().append("g") .attr("class", d => `trip-${d.tripId}`) .append("circle") .attr("fill", d => c(d.scenario)) .attr("stroke", "none") .attr("r", 2) .attr("cx", d => x(d.time)) .attr("cy", d => y(d.distance)) .merge(tripEndpoint); svg.node().update = (newData) => { const t = svg.transition() .duration(750); tripEndpoint.data(newData, d => d.tripId) .transition(t) .attr("fill", d => c(d.scenario)) .attr("cx", d => x(d.time)) .attr("cy", d => y(d.distance)); }; return svg.node(); }
Level Three: Individual Trips The third level drilled down into the leg-by-leg routing of each trip. At this level, only individual trips are able to be visualized efficiently. Individual Trip Legs Using leg-by-leg results enables comparisons of walk times, wait times, transit times, transfers, and more for a given trip.
viewof trip = select({ description: "Select a trip to examine", options: filteredTrips.map(d => d.key) })
lineChart = { const xDomain = [0, d3.max(selectedTrip.values.map(d => d3.max(d.values.map(e => e.time))))]; const yDomain = [0, d3.max(selectedTrip.values.map(d => d3.max(d.values.map(e => e.distance))))]; const margin = ({top: 20, right: 40, bottom: 30, left: 40}); const x = d3.scaleLinear() .domain(xDomain) .range([margin.left, width - margin.right]) .clamp(false) .nice(); const y = d3.scaleLinear() .domain(yDomain) .range([height - margin.bottom, margin.top]) .clamp(false) .nice(); const xAxis = g => g .attr("transform", `translate(0,${height - margin.bottom})`) .call(d3.axisBottom(x).tickFormat(formatMinutes)) .call(g => g.select(".domain").remove()); const yAxis = g => g .attr("transform", `translate(${margin.left},0)`) .call(d3.axisLeft(y).ticks(height / 80).tickFormat(d => d + " mi")) .call(g => g.select(".domain").remove()); const line = d3.line() .x(d => x(d.time)) .y(d => y(d.distance)); const svg = d3.select(DOM.svg(width, height)); const gx = svg.append("g") .call(xAxis); const gy = svg.append("g") .call(yAxis); const cDomain = ["WAIT", "WALK", "BUS"]; const cBaseRange = [d3.rgb(baseColor).darker(), baseColor, d3.rgb(baseColor).brighter()]; const cAltRange = [d3.rgb(altColor).darker(), altColor, d3.rgb(altColor).brighter()]; const cBase = d3.scaleOrdinal() .domain(cDomain) .range(cBaseRange); const cAlt = d3.scaleOrdinal() .domain(cDomain) .range(cAltRange); const scenarios = svg.append("g") .selectAll("g") .data(selectedTrip.values) .enter().append("g") .attr("class", (d, i) => `scenario-${i}`); const scenariosEnter = scenarios .selectAll("line") .data(d => d.values) .enter(); const tooltipText = (d) => { const humanizedDuration = moment.duration(d.time - d.startTime, "minutes").humanize(); if (d.legType === "BUS") { return `${d.legType} (${d.route}): ${humanizedDuration}` } else { return `${d.legType}: ${humanizedDuration}` } } scenariosEnter .append("line") .attr("fill", "none") .attr("stroke-width", 15.0) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .attr("stroke", "#ffffff") .attr("x1", d => x(d.startTime)) .attr("x2", d => x(d.time)) .attr("y1", d => y(d.startDistance)) .attr("y2", d => y(d.distance)) .on("mouseover", function(d) { tooltip.style("display", null); }) .on("mouseout", function(d) { tooltip.style("display", "none"); }) .on("mousemove", function(d) { var xPosition = parseInt(d3.mouse(this)[0]); var yPosition = parseInt(d3.mouse(this)[1] - 25); tooltip.attr("transform", "translate(" + xPosition + "," + yPosition + ")"); tooltip.select("text").text(tooltipText(d)); }); scenariosEnter .append("line") .attr("fill", "none") .attr("stroke-width", 5.0) .attr("stroke-linejoin", "round") .attr("stroke-linecap", "round") .attr("stroke", d => d.scenario === "BASE" ? cBase(d.legType) : cAlt(d.legType)) .attr("x1", d => x(d.startTime)) .attr("x2", d => x(d.time)) .attr("y1", d => y(d.startDistance)) .attr("y2", d => y(d.distance)) .attr("pointer-events", "none"); const tooltip = svg.append("g") .attr("class", "tooltip") .style("display", "none"); tooltip.append("rect") .attr("x", -75) .attr("width", 150) .attr("height", 20) .attr("fill", "white") .style("opacity", 1); tooltip.append("text") .attr("x", 0) .attr("dy", "1.2em") .attr("fill", "#444444") .style("text-anchor", "middle") .attr("font-size", "12px") .attr("font-weight", "bold"); const baseLegend = svg.append("g") .attr("class", "legend") .attr("transform", `translate(${width - 200}, ${height - 200})`); baseLegend.append("text") .attr("x", 0) .attr("y", -20) .attr("dy", "1.2em") .attr("fill", "#444444") .style("text-anchor", "left") .attr("font-size", "16px") .attr("font-weight", "bold") .text("Baseline"); createLegend(baseLegend, cBase); const altLegend = svg.append("g") .attr("class", "legend") .attr("transform", `translate(${width - 100}, ${height - 200})`); altLegend.append("text") .attr("x", 0) .attr("y", -20) .attr("dy", "1.2em") .attr("fill", "#444444") .style("text-anchor", "left") .attr("font-size", "16px") .attr("font-weight", "bold") .text("Alternative"); createLegend(altLegend, cAlt); return svg.node(); }
createLegend = (legend, colorScale, scenario) => { const yPadding = 20; colorScale.domain().forEach((type, i) => { legend.append("rect") .attr("x", 0) .attr("y", yPadding * (i + 1)) .attr("width", 20) .attr("height", 5) .attr("fill", colorScale(type)); legend.append("text") .attr("x", 30) .attr("y", yPadding * (i + 1) - 7) .attr("dy", "1.2em") .attr("fill", "#444444") .style("text-anchor", "left") .attr("font-size", "12px") .attr("font-weight", "bold") .text(type); }); }
Behind the Scenes This section includes the code used behind the scenes to obtain and visualize the data.
OpenTripPlanner Batch Processing The code below was used to route trips from the onboard origin-destination survey. Information about those trips was contained in the batchIn.csv, which has a column structure similar to the sample below. Values have been randomly offset for privacy. | ID | UL_WEIGHT | TRANSFERS | L_WEIGHT |Start date| StartTime | StartLat | StartLon | EndLat | EndLon| TIME| |:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | 7132 | 1.83 |3 |0.45 |2/21/2017 |11:37:12 AM| 28.5| -81.3| 28.6| -81.3| 11-12 pm|
html`<iframe width=${width} height=400 src="data:text/html;charset=utf-8,%3Cbody%3E%3Cscript%20src%3D%22https%3A%2F%2Fgist.github.com%2Fnselikoff%2F11471cdccfb91045a332f6105162f191.js%22%3E%3C%2Fscript%3E%3C%2Fbody%3E">`
Input Data Manipulating the results of the trip routing workflow.
TripLines = await d3.csv('https://gist.githubusercontent.com/nselikoff/2914d66e7aa315b430d81ef8d3c75b72/raw/933e7a58882d5a9bb4ea2051345945b5474347e8/TripLines.csv', d => { return { tripId: d.TRIP_ID, scenario: d.SCENARIO, legNum: +d.LEG_NUM, legType: d.LEG_TYPE, time: +d.TIME_MIN, distance: +d.DISTANCE_MI, route: d.ROUTE }; })
parseDate = d3.timeParse("%m/%d/%Y %I:%M:%S %p")
TripAttributes = await d3.csv('https://gist.githubusercontent.com/nselikoff/ebb01b6d7e3f3974c04c17b7a28f644d/raw/183a18ea53140fc5f9349821a2a982093459b982/TripAttributesOffset.csv', d => { const startDateTime = parseDate(d["Start date"] + " " + d.StartTime); return { tripId: d.ID, unlinkedWeekdayWeightFactor: +d.UNLINKED_WKDAY_WGHT_FACTOR, totalTransfers: +d.TOTAL_TRANSFERS, linkedWeightFactor: +d.LINKED_WGHT_FCTR, startDateTime: startDateTime, startLat: +d.StartLat, startLon: +d.StartLon, endLat: +d.EndLat, endLon: +d.EndLon, time: d.time }; })
TripAlternativeUsage = await d3.csv('https://gist.githubusercontent.com/nselikoff/3c1f6ec9e8fe3edc9aeace2232ed13ab/raw/d4fd703f8f896e5f8934fd33c6c147c32c5b7fd2/TripAlternativeUsage.csv', d => { const usedAlt = d.USED_ALT === '1'; return { tripId: d.TRIP_ID, usedAlt: usedAlt }; })
encodedShapes = (await d3.json('https://gist.githubusercontent.com/nselikoff/70e5f06b32d6ba84960c854f78fb8f00/raw/48467b2c637485f4ec50552db0b53351f514907c/encodedShapes.json')).data
Additional Data Processing This code filters the trips to those that would take the project alternative and performs additional operations.
filteredTrips = d3.nest() .key(function(d) { return d.tripId; }).sortKeys(d3.ascending) .key(function(d) { return d.scenario; }).sortKeys(d3.ascending) .entries(TripLines) .filter((d) => { let tripsUsingAlt = TripAlternativeUsage.filter(d => d.usedAlt).map(d => d.tripId); return tripsUsingAlt.includes(d.key); }) .sort((a, b) => a.key - b.key)
selectedTrip = { let obj = filteredTrips.filter(d => d.key === trip)[0]; obj.values.forEach((scenario) => { let prevTime = 0; let prevDistance = 0; scenario.values.forEach((leg) => { leg.startTime = prevTime; leg.startDistance = prevDistance; prevTime = leg.time; prevDistance = leg.distance; }); }); return obj; }
filteredTripEndpoints = d3.nest() .key(function(d) { return d.tripId; }).sortKeys(d3.ascending) .key(function(d) { return d.scenario; }).sortKeys(d3.ascending) .rollup(function(legs) { return legs.pop(); }) .entries(TripLines) .filter((d) => { let tripsUsingAlt = TripAlternativeUsage.filter(d => d.usedAlt).map(d => d.tripId); return tripsUsingAlt.includes(d.key); })
filteredTripEndpointsBase = filteredTripEndpoints.map(d => d.values.filter(e => e.key === "BASE")[0].value)
deltas = filteredTripEndpoints.map((d) => { return { tripId: d.key, beforeTime: d.values[0].value.time, beforeDistance: d.values[0].value.distance, afterTime: d.values[1].value.time, afterDistance: d.values[1].value.distance } })
origins = GeoJSON.parse(TripAttributes, {Point: ['startLat', 'startLon']})
destinations = GeoJSON.parse(TripAttributes, {Point: ['endLat', 'endLon']})
selectedLocations = toggle2 === "destinations" ? destinations : origins
beforeTimes = deltas.map(d => d.beforeTime)
afterTimes = deltas.map(d => d.afterTime)
Libraries Open source libraries used to visualize the data.
import {select, checkbox, color} from "@jashkenas/inputs"
d3 = require("https://d3js.org/d3.v5.min.js")
moment = require('moment@2.22.2/moment.js')
L = require('leaflet@1.2.0')
html`<link href='${resolve('leaflet@1.2.0/dist/leaflet.css')}' rel='stylesheet' />`
heatLayer = L, require('leaflet.heat').catch(() => L.heatLayer)
GeoJSON = require('geojson@0.5.0/geojson').catch(() => window.GeoJSON)
polyUtil = require('https://bundle.run/polyline-encoded@0.0.8')
// Adapted from https://bl.ocks.org/mbostock/4061502 d3.box = function() { function boxWhiskers(d) { return [0, d.length - 1]; } function boxQuartiles(d) { return [ d3.quantile(d, .25), d3.quantile(d, .5), d3.quantile(d, .75) ]; } var width = 1, height = 1, duration = 0, domain = null, value = Number, whiskers = boxWhiskers, quartiles = boxQuartiles, tickFormat = null; function d3_functor(v) { return typeof v === "function" ? v : function() { return v; }; } // For each small multiple… function box(g) { g.each(function(d, i) { d = d.map(value).sort(d3.ascending); var g = d3.select(this), n = d.length, min = d[0], max = d[n - 1]; // Compute quartiles. Must return exactly 3 elements. var quartileData = d.quartiles = quartiles(d); // Compute whiskers. Must return exactly 2 elements, or null. var whiskerIndices = whiskers && whiskers.call(this, d, i), whiskerData = whiskerIndices && whiskerIndices.map(function(i) { return d[i]; }); // Compute outliers. If no whiskers are specified, all data are "outliers". // We compute the outliers as indices, so that we can join across transitions! var outlierIndices = whiskerIndices ? d3.range(0, whiskerIndices[0]).concat(d3.range(whiskerIndices[1] + 1, n)) : d3.range(n); // Compute the new x-scale. var x1 = d3.scaleLinear() .domain(domain && domain.call(this, d, i) || [min, max]) .range([height, 0]); // Retrieve the old x-scale, if this is an update. var x0 = this.__chart__ || d3.scaleLinear() .domain([0, Infinity]) .range(x1.range()); // Stash the new scale. this.__chart__ = x1; // Note: the box, median, and box tick elements are fixed in number, // so we only have to handle enter and update. In contrast, the outliers // and other elements are variable, so we need to exit them! Variable // elements also fade in and out. // Update center line: the vertical line spanning the whiskers. var center = g.selectAll("line.center") .data(whiskerData ? [whiskerData] : []); center.enter().insert("line", "rect") .attr("class", "center") .attr("x1", width / 2) .attr("y1", function(d) { return x0(d[0]); }) .attr("x2", width / 2) .attr("y2", function(d) { return x0(d[1]); }) .style("opacity", 1e-6) .transition() .duration(duration) .style("opacity", 1) .attr("y1", function(d) { return x1(d[0]); }) .attr("y2", function(d) { return x1(d[1]); }); center.transition() .duration(duration) .style("opacity", 1) .attr("y1", function(d) { return x1(d[0]); }) .attr("y2", function(d) { return x1(d[1]); }); center.exit().transition() .duration(duration) .style("opacity", 1e-6) .attr("y1", function(d) { return x1(d[0]); }) .attr("y2", function(d) { return x1(d[1]); }) .remove(); // Update innerquartile box. var box = g.selectAll("rect.box") .data([quartileData]); box.enter().append("rect") .attr("class", "box") .attr("x", 0) .attr("y", function(d) { return x0(d[2]); }) .attr("width", width) .attr("height", function(d) { return x0(d[0]) - x0(d[2]); }) .transition() .duration(duration) .attr("y", function(d) { return x1(d[2]); }) .attr("height", function(d) { return x1(d[0]) - x1(d[2]); }); box.transition() .duration(duration) .attr("y", function(d) { return x1(d[2]); }) .attr("height", function(d) { return x1(d[0]) - x1(d[2]); }); // Update median line. var medianLine = g.selectAll("line.median") .data([quartileData[1]]); medianLine.enter().append("line") .attr("class", "median") .attr("x1", 0) .attr("y1", x0) .attr("x2", width) .attr("y2", x0) .transition() .duration(duration) .attr("y1", x1) .attr("y2", x1); medianLine.transition() .duration(duration) .attr("y1", x1) .attr("y2", x1); // Update whiskers. var whisker = g.selectAll("line.whisker") .data(whiskerData || []); whisker.enter().insert("line", "circle, text") .attr("class", "whisker") .attr("x1", 0) .attr("y1", x0) .attr("x2", width) .attr("y2", x0) .style("opacity", 1e-6) .transition() .duration(duration) .attr("y1", x1) .attr("y2", x1) .style("opacity", 1); whisker.transition() .duration(duration) .attr("y1", x1) .attr("y2", x1) .style("opacity", 1); whisker.exit().transition() .duration(duration) .attr("y1", x1) .attr("y2", x1) .style("opacity", 1e-6) .remove(); // Update outliers. var outlier = g.selectAll("circle.outlier") .data(outlierIndices, Number); outlier.enter().insert("circle", "text") .attr("class", "outlier") .attr("r", 5) .attr("cx", width / 2) .attr("cy", function(i) { return x0(d[i]); }) .style("opacity", 1e-6) .transition() .duration(duration) .attr("cy", function(i) { return x1(d[i]); }) .style("opacity", 1); outlier.transition() .duration(duration) .attr("cy", function(i) { return x1(d[i]); }) .style("opacity", 1); outlier.exit().transition() .duration(duration) .attr("cy", function(i) { return x1(d[i]); }) .style("opacity", 1e-6) .remove(); // Compute the tick format. var format = tickFormat || x1.tickFormat(8); // Update box ticks. var boxTick = g.selectAll("text.box") .data(quartileData); boxTick.enter().append("text") .attr("class", "box") .attr("dy", ".3em") .attr("dx", function(d, i) { return i & 1 ? 6 : -6 }) .attr("x", function(d, i) { return i & 1 ? width : 0 }) .attr("y", x0) .attr("text-anchor", function(d, i) { return i & 1 ? "start" : "end"; }) .text(format) .transition() .duration(duration) .attr("y", x1); boxTick.transition() .duration(duration) .text(format) .attr("y", x1); // Update whisker ticks. These are handled separately from the box // ticks because they may or may not exist, and we want don't want // to join box ticks pre-transition with whisker ticks post-. var whiskerTick = g.selectAll("text.whisker") .data(whiskerData || []); whiskerTick.enter().append("text") .attr("class", "whisker") .attr("dy", ".3em") .attr("dx", 6) .attr("x", width) .attr("y", x0) .text(format) .style("opacity", 1e-6) .transition() .duration(duration) .attr("y", x1) .style("opacity", 1); whiskerTick.transition() .duration(duration) .text(format) .attr("y", x1) .style("opacity", 1); whiskerTick.exit().transition() .duration(duration) .attr("y", x1) .style("opacity", 1e-6) .remove(); }); d3.timerFlush(); } box.width = function(x) { if (!arguments.length) return width; width = x; return box; }; box.height = function(x) { if (!arguments.length) return height; height = x; return box; }; box.tickFormat = function(x) { if (!arguments.length) return tickFormat; tickFormat = x; return box; }; box.duration = function(x) { if (!arguments.length) return duration; duration = x; return box; }; box.domain = function(x) { if (!arguments.length) return domain; domain = x == null ? x : d3_functor(x); return box; }; box.value = function(x) { if (!arguments.length) return value; value = x; return box; }; box.whiskers = function(x) { if (!arguments.length) return whiskers; whiskers = x; return box; }; box.quartiles = function(x) { if (!arguments.length) return quartiles; quartiles = x; return box; }; return box; }
Other Configuration and Helper Functions
viewof baseColor = color({ value: "#FF7F00", title: "Base Color", description: "Color used for baseline data" })
viewof altColor = color({ value: "#1676B6", title: "Alternative Color", description: "Color used for project alternative data" })
html` <style> .base-color { color: ${baseColor}; } .alt-color { color: ${altColor}; } .box { font: 10px sans-serif; } .box line, .box rect, .box circle { fill: #fff; stroke: #000; stroke-width: 1.5px; } .box.base-color line, .box.base-color rect, .box.base-color circle { stroke: ${baseColor}; } .box.alt-color line, .box.alt-color rect, .box.alt-color circle { stroke: ${altColor}; } .box .center { stroke-dasharray: 3,3; } .box .outlier { fill: none; stroke: #ccc; } </style> `
{ let data; if (toggle === "alt") { data = filteredTripEndpoints.map(d => d.values.filter(e => e.key === "WITH PROJECT")[0].value); } else { data = filteredTripEndpoints.map(d => d.values.filter(e => e.key === "BASE")[0].value); } scatterplot.update(data); return toggle; }
height = 500
formatMinutes = (d) => { let duration = moment.duration(d, 'minutes'); let minutes = duration.minutes().toString(); minutes = minutes.length === 1 ? "0" + minutes : minutes; if (minutes.startsWith("-")) { return `-${duration.hours()}:${minutes.substring(1)}`; } else { return duration.hours() + ":" + minutes; } }
Resources This interactive notebook was created using Observable, d3, moment.js and Leaflet.