WF-TDM Calibration
  • Follow Us
    • Facebook
    • X (Twitter)
    • Instagram
    • LinkedIn
    • YouTube
  • Source Code
  • Report a Bug
  1. Reports
  2. Mode Choice
  • TDM Calibration Reports
  • Reports
    • Household Disaggregation and Auto Ownership
    • Trip Generation
    • Trip Distribution & HBW Destination Choice
    • Mode Choice
    • Highway Assignment
  • Commercial Vehicle Model Update
  1. Reports
  2. Mode Choice

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.

html`<br/>`
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 []
  }
}
  • Chart
  • Table
{
  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}`
}
Figure 1: Mode Share Calibration Results by Trip Purpose
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>`
html`<br/><br/>`

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"
  )
]))
transitColorScale = d3.scaleOrdinal(["Model", "Target"], [themeModel, themeTarget])
keyTransitDumbbell = Legend(transitColorScale, {title: "Data Source"})
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>`;
}
(a)
(b)
(c)
(d)
(e)
Figure 2: Trips and Boardings by Mode – Model vs Target Comparison
html`<br/>`

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;
})
  • Chart
  • Table
// 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])
  ]
})
Figure 3: 2023 Average Daily CRT Boardings by Station - Model vs Target
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

// 1. Transpose Python data into row-oriented JS objects
crtData = transpose(crtDistFreq_py)
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]),
  ],
})
Figure 4: CRT Trip Distance Frequency — Model vs Target
Trip Distribution & HBW Destination Choice
Highway Assignment

© 2025 Wasatch Front Regional Council

  • Contact

  • License