Mode Choice
viewof globalCalibRun = {
const options = calib_runs_ojs;
if (options.length === 0) {
return Inputs.select([], {label: "Calibration Run:", disabled: true});
}
// Default to the run with the highest numeric ID (e.g. "C51" over "C50"),
// not just the last entry in `options` -- that list comes from a plain
// alphabetical sort (see list_available_runs in _validation_scripts.py),
// which only happens to match numeric order today because both IDs have
// the same digit count ("C9" would sort after "C50" alphabetically despite
// being numerically earlier).
const runNumber = id => {
const m = /(\d+)/.exec(id);
return m ? parseInt(m[1], 10) : -Infinity;
};
const latestRun = options.reduce((best, id) => runNumber(id) > runNumber(best) ? id : best, options[0]);
const stored = window.localStorage.getItem("tdmcalib:selectedRun");
const initial = options.includes(stored) ? stored : latestRun;
const input = Inputs.select(options, {label: "Calibration Run:", value: initial});
const selectEl = input.querySelector("select");
input.classList.add("calib-run-bar");
// Sidebar links for pages that don't have data for every run (currently
// just report/cvm-update.qmd, which is only ever generated for the model
// version's reference run -- not planned for every run the way the other
// report pages are) get greyed out -- rather than the run dropdown itself
// shrinking to that page's subset -- so the dropdown's options stay
// identical everywhere on the site. The link stays clickable. If the
// current page happens to be cvm-update.qmd itself, this also swaps its
// own #cvm-content/#cvm-empty-message visibility -- both elements are
// absent on every other page, so this is a no-op there.
function updateSidebarAvailability(run) {
const available = cvm_update_available_runs_ojs.includes(run);
const link = document.querySelector('#quarto-sidebar a[href$="cvm-update.html"]');
if (link) {
const item = link.closest(".sidebar-item") ?? link;
item.classList.toggle("sidebar-item-no-run-data", !available);
link.title = available ? "" : `No commercial-vehicle factoring data for ${run}`;
}
const content = document.getElementById("cvm-content");
const emptyMsg = document.getElementById("cvm-empty-message");
if (content) content.style.display = available ? "" : "none";
if (emptyMsg) emptyMsg.style.display = available ? "none" : "";
}
updateSidebarAvailability(initial);
input.addEventListener("input", () => {
window.localStorage.setItem("tdmcalib:selectedRun", input.value);
updateSidebarAvailability(input.value);
});
// Quarto's own OJS runtime places this cell's returned node into its
// normal in-page output slot right after this function returns -- moving
// it into the header/sidebar synchronously, before that happens, would
// just get undone a moment later. Deferring to the next tick runs this
// after that placement, so the move actually sticks.
setTimeout(() => {
const navbarBrand = document.querySelector("#quarto-header .navbar-brand-container");
if (navbarBrand) {
input.classList.remove("calib-run-bar");
input.classList.add("calib-run-navbar");
navbarBrand.appendChild(input);
return;
}
const sidebar = document.querySelector("#quarto-sidebar");
const menu = sidebar?.querySelector(".sidebar-menu-container");
if (!sidebar || !menu) return; // no navbar brand container or docked sidebar -- stays inline as a .calib-run-bar
// Inputs.select renders the label text and <select> on the same line by
// default -- force them onto separate lines to match the sidebar's
// stacked item style. Insert relative to the select's actual parent
// (not assumed to be the <label>, which varies by Inputs version) so
// this doesn't throw if they aren't nested.
selectEl?.parentNode.insertBefore(document.createElement("br"), selectEl);
input.classList.remove("calib-run-bar");
input.classList.add("calib-run-sidebar");
sidebar.insertBefore(input, menu);
}, 0);
return input;
}themeModel = "#ea7b00" // WFRC core orange
themeTarget = "#036fa8" // WFRC core blue
themeTargetAlt = "#007426" // WFRC core green
themeTargetLight = "#8ebed8" // Lighter tint of Target blue (#036fa8)
themeAccent = "#9d4879" // WFRC RTP fuschia
themeNeutral = "#7f7a76" // WFRC brand gray
themeDanger = "#d32f00" // WFRC core red -- status color, pair with a label/icon, never color alone
themeLinkGrey = "#d8d7d6"Mode Share
The mode choice model was calibrated to target mode shares from the 2023 Utah Household Travel Survey and the 2024 Transit On-Board Survey. The results of this calibration effort are shown in Figure 1. All mode shares were calibrated to within 5% of target data.
viewof plotSelect = Inputs.select(
[
"Motorized / Non-Motorized",
"Auto / Transit",
"Drive Alone / Shared Ride",
"Shared Ride # of Occupants",
"Transit Mode",
"Transit Access Mode"
],
{value: "Motorized / Non-Motorized", label: "Mode Group:"}
)
// Removed: viewof purposeSelect
dataLT = transpose(dataLong)
dataTT = transpose(dataTable)
// All purposes including "All" for the facet
facetPurposes = ["All", "HBW", "HBC", "HBO", "NHB"]
facetLabels = new Map([
["All", "All Purposes"],
["HBW", "Home-Based Work"],
["HBC", "Home-Based College"],
["HBO", "Home-Based Other"],
["NHB", "Non-Home Based"]
])
// Time periods now facet as chart rows instead of a Time Period select
facetPeriods = ["DY", "PK", "OK"]
periodLabels = new Map([["DY", "Daily"], ["PK", "Peak"], ["OK", "Off-Peak"]])
// Build one filtered dataset per purpose x period facet cell
filtered_data = facetPurposes.flatMap(purp =>
facetPeriods.flatMap(per =>
dataLT
.filter(d =>
plotSelect === d.ModeGroup &&
per === d.Period &&
purp === d.TripPurpose &&
d.Mode !== "Core Bus" &&
d.calib_run === globalCalibRun
)
.map(d => ({...d, PurposeLabel: facetLabels.get(purp), PeriodLabel: periodLabels.get(per)}))
)
)
table_filtered_data = dataTT
.filter(d =>
plotSelect === d.ModeGroup &&
d.Mode !== "Core Bus" &&
d.calib_run === globalCalibRun
)
.map(d => ({...d, PeriodLabel: periodLabels.get(d.Period) ?? d.Period}))
import {Legend, Swatches} from "@d3/color-legend"
import {howto, altplot} from "@d3/example-components"
formatPercent = d3.format(".1%")
// Mode Share chart only (@fig-mode-share) -- drops the decimal above 1% since
// it's just noise at that scale, but keeps it below 1% where it's the only
// thing distinguishing a rare mode's share from zero.
fmtSharePercent = d3.format(".0%")
fmtSharePercentFine = d3.format(".1%")
function fmtShare(value) {
return Math.abs(value) > 0.01 ? fmtSharePercent(value) : fmtSharePercentFine(value);
}
xDomain = {
const sources = ["Model", "Target"]
switch(plotSelect) {
case "Motorized / Non-Motorized":
return ['Motorized', 'Non-Motorized'].flatMap(m => sources.map(s => `${m}||${s}`))
case "Auto / Transit":
return ['Auto', 'Transit'].flatMap(m => sources.map(s => `${m}||${s}`))
case "Drive Alone / Shared Ride":
return ['Drive Alone', 'Shared Ride'].flatMap(m => sources.map(s => `${m}||${s}`))
case "Shared Ride # of Occupants":
return ['Shared Ride 2 Occupants', 'Shared Ride 3+ Occupants'].flatMap(m => sources.map(s => `${m}||${s}`))
case "Transit Mode":
return ['Local Bus', 'Express Bus', 'BRT', 'LRT', 'CRT'].flatMap(m => sources.map(s => `${m}||${s}`))
case "Transit Access Mode":
return ['Walk', 'Drive'].flatMap(m => sources.map(s => `${m}||${s}`))
default: return []
}
}{
const legend = Legend(
d3.scaleOrdinal(["Model", "Target"], [themeModel, themeTarget]),
{title: "Data Source"}
)
const chart = Plot.plot({
// marginTop/marginBottom/facetMarginTop (150px) are paid once for the
// whole 3-row facet grid, not per row, so shrinking each row means
// shrinking the shared content band, not the total height: fixed
// margins (150) + 80% of the prior content band (473 * 0.8 = 378.4),
// i.e. 20% off the 623px version.
height : 528,
width : 950,
marginLeft : 55,
marginBottom: 80,
marginTop : 40,
facetMarginTop: 30,
facetMarginRight: 60,
style: { backgroundColor: "transparent", fontSize: "0.85em", fontFamily: "Inter, system-ui, sans-serif" },
fx: {
domain: ["All Purposes", "Home-Based Work", "Home-Based College", "Home-Based Other", "Non-Home Based"],
label: null,
tickSize: 0,
axis: "top",
},
fy: {
domain: ["Daily", "Peak", "Off-Peak"],
label: null,
tickSize: 0,
// Plot's facet padding is a fraction of each row's band step, not a
// literal pixel gap -- doubled again from 0.2 to 0.4 (Plot's default
// is 0.1) for another doubling of row gap; nudge higher/lower if
// it reads off after previewing.
padding: 0.4,
},
x: {
domain: xDomain,
axis: null,
padding: 0.2,
label: null,
},
y: {
grid: true,
label: "Share",
tickFormat: ".0%",
domain: [0, 1],
},
color: {
domain: ["Model", "Target"],
range: [themeModel, themeTarget],
},
marks: [
Plot.barY(filtered_data, {
fx : "PurposeLabel",
fy : "PeriodLabel",
x : d => `${d.Mode}||${d.DataSource}`,
y : "Percent",
fill : "DataSource",
sort : { x: null },
tip : {
format: {
x : d => d.split("||")[0],
y : d => fmtShare(d),
fill : true,
}
}
}),
Plot.text(filtered_data, {
fx : "PurposeLabel",
fy : "PeriodLabel",
x : d => `${d.Mode}||${d.DataSource}`,
y : "Percent",
text : d => fmtShare(d.Percent),
textAnchor : "middle",
lineAnchor : "bottom",
dy : -4,
fontSize : 9,
}),
Plot.text(
xDomain.filter(d => d.endsWith("||Model")),
{
x : d => d,
text : d => d.split("||")[0].replace(/ /g, "\n"),
frameAnchor: "bottom",
lineAnchor : "top",
dy : 6,
fontSize : 11,
lineHeight : 1.2,
}
),
Plot.ruleY([0]),
],
})
return html`${legend}${chart}`
}html`
<br/>
<table>
<thead>
<tr>
${["Mode", "Purpose", "Period", "Model", "Target", "Difference"].map((d, i) =>
html`<th style='text-align: ${i <= 2 ? "left" : "right"}; padding: 5px; ${i <= 2 ? "width: 140px;" : ""}'>${d}</th>`)}
</tr>
</thead>
<tbody>
${table_filtered_data.map(row => html`
<tr style='border-bottom: 1px solid lightgrey;'>
${["Mode", "TripPurpose", "PeriodLabel", "Model", "Target", "Difference"].map((d, i) => {
const val = (i <= 2) ? row[d] : formatPercent(row[d]);
return html`<td style='text-align: ${i <= 2 ? "left" : "right"}; padding: 5px; ${i <= 2 ? "width: 140px;" : ""}'>${val}</td>`
})}
</tr>`)}
</tbody>
</table>`Transit Trips and Boardings
Each chart below is a dumbbell chart comparing Model to Target by mode for one category, with its data table directly beneath it – the connecting line’s length is the gap, and the percent label is the model’s difference from target. “All” is the system-wide total across every mode. Transfer Ratio (model boardings per model trip) isn’t a count, so it keeps its own 2-decimal scale rather than sharing an axis with the trip/boarding counts.
dataBTT = transpose(boardTable)
transitCategories = ["Trips by Hierarchical Mode", "Boardings by Hierarchical Mode", "Transfer Ratio", "Boardings by Mode Surveyed"]
transitCategoryLabels = new Map([
["Trips by Hierarchical Mode", "Trips by Hierarchical Mode"],
["Boardings by Hierarchical Mode", "Boardings by Hierarchical Mode"],
["Transfer Ratio", "Transfer Ratio by Hierarchical Mode"],
["Boardings by Mode Surveyed", "Boardings by Observed Mode"]
])
// Same fixed mode order regardless of category (matches the row order the
// removed table/bar chart used). "All" (the system-wide subtotal row
// already present in boardTable) is excluded -- it dwarfs every individual
// mode, which forced the shared x-axis out so far that per-mode dumbbells
// were unreadable.
transitModeOrder = ["Local Bus", "Express Bus", "BRT", "LRT", "CRT"]
transitDataByCategory = new Map(transitCategories.map(cat => [
cat,
dataBTT.filter(d =>
d.Title === cat &&
d.calib_run === globalCalibRun &&
d.Mode !== "Core Bus" && // <--- EXCLUDE CORE BUS
d.Mode !== "All"
)
]))transitGridBreakpoint = 700
transitGridGap = 24
function transitDumbbellChart(category, data) {
const isRatio = category === "Transfer Ratio";
const fmtVal = isRatio ? d3.format(",.2f") : d3.format(",.0f");
const xLabel = isRatio ? "Transfer Ratio" : (category.startsWith("Trips") ? "Daily Trips" : "Daily Boardings");
const xMax = d3.max(data, d => Math.max(d.Model, d.Target)) || 1;
// Chart sits in one cell of the 2x2 grid (transit-dumbbell-grid below) --
// account for the cell being half the container width above the grid's
// breakpoint, and full width once the grid collapses to one column, so the
// plotted width matches what CSS actually gives this chart.
const chartWidth = width < transitGridBreakpoint ? width : (width - transitGridGap) / 2;
const chart = Plot.plot({
width: chartWidth,
height: 260,
marginLeft: 110,
marginRight: 60,
marginTop: 12,
y: {domain: transitModeOrder, label: null},
x: {label: xLabel, domain: [0, xMax], grid: true, nice: true},
marks: [
Plot.link(data, {
x1: "Model", x2: "Target", y: "Mode",
stroke: themeLinkGrey, strokeWidth: 3
}),
// Target drawn first, Model second, so the Model ring always sits on
// top where the two dots overlap.
Plot.dot(data, {
x: "Target", y: "Mode", fill: themeTarget, r: 10,
title: d => `${d.Mode}\nTarget: ${fmtVal(d.Target)}`
}),
Plot.dot(data, {
x: "Model", y: "Mode", fill: themeModel, r: 8,
title: d => `${d.Mode}\nModel: ${fmtVal(d.Model)}`
}),
Plot.text(data.filter(d => d.Model >= d.Target), {
x: "Model", y: "Mode",
text: d => d3.format("+.1%")(d["% Difference"]),
dx: 8, textAnchor: "start",
fontSize: 13, fontWeight: 600,
fill: d => Math.abs(d["% Difference"]) > 0.1 ? themeDanger : themeNeutral
}),
Plot.text(data.filter(d => d.Model < d.Target), {
x: "Model", y: "Mode",
text: d => d3.format("+.1%")(d["% Difference"]),
dx: -8, textAnchor: "end",
fontSize: 13, fontWeight: 600,
fill: d => Math.abs(d["% Difference"]) > 0.1 ? themeDanger : themeNeutral
})
]
});
return html`<div>${chart}</div>`;
}
// Per-category table (Mode, Model, Target, Difference, plus an "All" total
// row) shown directly beneath its own chart, rather than one shared
// all-categories table off to the side.
function transitCategoryTable(category, data) {
const fmt = category === "Transfer Ratio" ? d3.format(",.2f") : d3.format(",.0f");
const totalRow = dataBTT.find(d => d.Title === category && d.calib_run === globalCalibRun && d.Mode === "All");
const rows = [...data, totalRow].filter(d => d);
return html`<table style="width:100%; border-collapse:collapse; font-size: 12.5px; margin-top: 6px;">
<thead>
<tr>
<th style="text-align:left; padding:4px 6px; border-bottom:2px solid #999;">Mode</th>
<th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">Model</th>
<th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">Target</th>
<th style="text-align:right; padding:4px 6px; border-bottom:2px solid #999;">Difference</th>
</tr>
</thead>
<tbody>
${rows.map(d => {
const isTotal = d.Mode === "All";
const cellStyle = isTotal ? "padding: 3px 6px; border-top: 1px solid #ccc; font-weight:600;" : "padding: 3px 6px;";
return html`<tr>
<td style="${cellStyle}">${isTotal ? "Total" : d.Mode}</td>
<td style="${cellStyle} text-align:right;">${fmt(d.Model)}</td>
<td style="${cellStyle} text-align:right;">${fmt(d.Target)}</td>
<td style="${cellStyle} text-align:right;">${d3.format("+.1%")(d["% Difference"])}</td>
</tr>`;
})}
</tbody>
</table>`;
}
{
// 2x2 grid: one cell per category, chart on top and its table directly
// below it -- transitGridBreakpoint/transitGridGap (defined above) also
// size transitDumbbellChart's plotted width, so both stay in sync.
return html`<div>
<style>
.transit-dumbbell-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 28px ${transitGridGap}px;
}
@media (max-width: ${transitGridBreakpoint}px) {
.transit-dumbbell-grid {
grid-template-columns: 1fr;
}
}
</style>
<div class="transit-dumbbell-grid">
${transitCategories.map(cat => html`<div>
<h4>${transitCategoryLabels.get(cat)}</h4>
${transitDumbbellChart(cat, transitDataByCategory.get(cat))}
${transitCategoryTable(cat, transitDataByCategory.get(cat))}
</div>`)}
</div>
</div>`;
}Commuter Rail Station Boardings
data = transpose(data_py)
// 2. Create the Inputs
viewof access_select = Inputs.radio(
["All", "Walk", "Drive"],
{label: "Access Mode", value: "All"}
)
// 3. Filter Data based on selection -- keep observed rows (calib_run == null,
// e.g. On-Board Survey / Passenger Counts) always visible, only the selected
// run's Modeled rows
filtered_data_boarding = data.filter(d => {
if (d.calib_run != null && d.calib_run !== globalCalibRun) return false;
if (access_select === "All") return true;
return d.Access_Type === access_select;
})// 4. Render Plot
Plot.plot({
height: 450,
width: 900,
marginLeft: 50,
marginBottom: 100, // Extra space for rotated labels
// Global Style Adjustment
style: {
backgroundColor: 'transparent',
fontSize: "0.85em", // Increases base font size by ~1.4-1.5x
fontFamily: "Inter, system-ui, sans-serif"
},
// Facet X: The Station Names
fx: {
padding: 0.2, // Space between Station groups
label: null,
tickRotate: -45, // Inclined labels (bottom-up)
tickSize: 6,
domain: sort_order // Enforce geographic sort order
},
// Inner X: The Source (Hidden axis labels)
x: {
axis: null,
paddingOuter: 0.1
},
y: {
grid: true,
label: "Average Boardings",
tickFormat: "s"
},
color: {
legend: true,
domain: ["On-Board Survey", "Passenger Counts", "Modeled"],
range: [themeTarget, themeTargetAlt, themeModel]
},
marks: [
Plot.barY(filtered_data_boarding,
Plot.groupX(
{ y: "sum" }, // Sum boardings if multiple rows exist (e.g. Walk+Drive when 'Any')
{
x: "Source", // Separates by Source within Station
y: "Boardings",
fill: "Source",
fx: "Stop_Name", // Groups by Station
// --- CUSTOM TOOLTIP CONFIGURATION ---
tip: {
format: {
// 1. Format numbers with commas, no decimals
y: (d) => d.toLocaleString("en-US", {maximumFractionDigits: 0}),
// 2. Hide redundant fields
fx: false, // Hide "Stop_Name" (it's already in the header)
fill: false, // Hide "fill" (redundant with x/Source)
x: true // Keep "Source"
}
}
}
)
),
Plot.ruleY([0])
]
})pivoted_table_data = {
const current_sources = Array.from(new Set(filtered_data_boarding.map(d => d.Source))).sort();
const rollup = new Map();
for (const d of filtered_data_boarding) {
if (!rollup.has(d.Stop_Name)) rollup.set(d.Stop_Name, new Map());
const stopMap = rollup.get(d.Stop_Name);
const currentVal = stopMap.get(d.Source) || 0;
stopMap.set(d.Source, currentVal + d.Boardings);
}
const tableRows = sort_order.map(stop => {
const row = { "Station Name": stop };
for (const src of current_sources) {
const stopMap = rollup.get(stop);
row[src] = stopMap ? (stopMap.get(src) || 0) : 0;
}
return row;
});
// Create All Row (Keep as plain strings/numbers for now)
const totalRow = { "Station Name": "All" };
for (const src of current_sources) {
totalRow[src] = d3.sum(tableRows, d => d[src]);
}
return [...tableRows, totalRow];
}
// 2. Render the Table
Inputs.table(pivoted_table_data, {
columns: [
"Station Name",
...Object.keys(pivoted_table_data[0] || {}).filter(k => k !== "Station Name")
],
format: {
// Bold the "Station Name" if it is the All row
"Station Name": d => d === "All" ? html`<b>${d}</b>` : d,
// Apply formatting and bolding to all dynamic source columns
...Object.fromEntries(
Object.keys(pivoted_table_data[0] || {}).filter(k => k !== "Station Name").map(k => [
k, (val, i, data) => {
const isAll = data[i]["Station Name"] === "All";
const formatted = val.toLocaleString("en-US", {maximumFractionDigits: 0});
return isAll ? html`<b>${formatted}</b>` : formatted;
}
])
)
},
layout: "auto",
rows: 21
})Commuter Rail Distance Travelled
viewof crtPeriodSel = Inputs.select(
["Peak", "Off-Peak", "All"],
{label: "Period", value: "All"}
)
viewof crtAccessSel = Inputs.select(
["Walk", "Drive", "All"],
{label: "Access Mode", value: "All"}
)
viewof crtBinSize = Inputs.range([1, 25], {
step: 1, value: 5,
label: "Bin size (miles)"
})
viewof crtViewSel = Inputs.radio(
["Share (%)", "Trip Count"],
{label: "View", value: "Share (%)"}
)_periodCode = crtPeriodSel === "Peak" ? "PK" : crtPeriodSel === "Off-Peak" ? "OK" : null
_accessCode = crtAccessSel === "All" ? null : crtAccessSel
crtFiltered = crtData.filter(d =>
d.calib_run === globalCalibRun &&
(_periodCode === null || d.Period === _periodCode) &&
(_accessCode === null || d.AccessMode === _accessCode)
)
// ── 2. Build histogram bins for each Source ──────────────────────────────────
crtBinned = {
const rawMax = d3.max(crtFiltered, d => d.Distance);
const maxDist = rawMax ? Math.ceil(rawMax / crtBinSize) * crtBinSize : 120;
const thresholds = d3.range(0, maxDist + crtBinSize, crtBinSize);
const rows = [];
for (const source of ["Target", "Modeled"]) {
const src = crtFiltered.filter(d => d.Source === source);
const totalTrips = d3.sum(src, d => d.Trips);
for (let i = 0; i < thresholds.length - 1; i++) {
const lo = thresholds[i];
const hi = thresholds[i + 1];
const trips = d3.sum(
src.filter(d => d.Distance >= lo && d.Distance < hi),
d => d.Trips
);
rows.push({
BinLo : lo,
BinHi : hi,
BinMid : (lo + hi) / 2,
Trips : trips,
Share : totalTrips > 0 ? trips / totalTrips : 0,
Source : source,
});
}
}
return rows;
}// ── 3. Render chart ───────────────────────────────────────────────────────────
Plot.plot({
height : 420,
width : 860,
marginLeft : 65,
marginBottom: 55,
style: {
backgroundColor: "transparent",
fontSize: "0.85em",
fontFamily: "Inter, system-ui, sans-serif",
},
caption: [
crtPeriodSel,
crtAccessSel === "All" ? "All Access Modes" : `${crtAccessSel} Access`,
`${crtBinSize}-mile bins`
].join(" · "),
x: {
label: "CRT Trip Distance (miles) →",
tickSpacing: 60,
},
y: {
label: crtViewSel === "Trip Count" ? "↑ Number of Trips" : "↑ Share of Trips",
grid: true,
tickFormat: crtViewSel === "Trip Count"
? d => d.toLocaleString("en-US", {maximumFractionDigits: 0})
: d3.format(".1%"),
},
color: {
legend : true,
domain : ["Target", "Modeled"],
range : [themeTarget, themeModel],
},
marks: [
// Modeled (blue) — drawn first so Target sits on top where they overlap
Plot.areaY(crtBinned.filter(d => d.Source === "Modeled"), {
x : "BinLo",
y : crtViewSel === "Trip Count" ? "Trips" : "Share",
fill : themeModel,
stroke : themeModel,
fillOpacity: 0.35,
strokeWidth: 1.5,
curve : "step-after",
}),
// Target (green)
Plot.areaY(crtBinned.filter(d => d.Source === "Target"), {
x : "BinLo",
y : crtViewSel === "Trip Count" ? "Trips" : "Share",
fill : themeTarget,
stroke : themeTarget,
fillOpacity: 0.35,
strokeWidth: 1.5,
curve : "step-after",
}),
Plot.tip(crtBinned, Plot.pointerX({
x : "BinMid",
y : crtViewSel === "Trip Count" ? "Trips" : "Share",
title: d => {
const val = crtViewSel === "Trip Count"
? d.Trips.toLocaleString("en-US", {maximumFractionDigits: 0})
: d3.format(".1%")(d.Share);
return `${d.Source}\n${d.BinLo}–${d.BinHi} mi\n${val}`;
},
})),
Plot.ruleY([0]),
],
})