WF-TDM Calibration
  • Follow Us
    • Facebook
    • X (Twitter)
    • Instagram
    • LinkedIn
    • YouTube
  • Source Code
  • Report a Bug
  1. Reports
  2. Household Disaggregation and Auto Ownership
  • 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. Household Disaggregation and Auto Ownership

Household Disaggregation and Auto Ownership

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"

Household Disaggregation

Modeled vs. Target

hh_data = transpose(hhdis_data);

// Sorted unique values for facet dimensions (HHSIZE = cols, WRKS = rows)
hh_hhsize_vals = Array.from(new Set(hh_data.map(d => d.HHSIZE))).sort((a, b) => a - b)
hh_wrks_vals   = Array.from(new Set(hh_data.map(d => d.WRKS))).sort((a, b) => a - b)
inc_list       = ["Total", "<$50k", "$50k–$100k", "$100k–$150k", ">$150k"]
viewof vGeoLevel = Inputs.select(["CO_FIPS", "PUMA"],
  { label: "Geography Level:", value: "CO_FIPS" })

viewof vINC = Inputs.select(inc_list,
  { label: "Income Level:", value: inc_list[0] })
viewof vXAxis = Inputs.select(["Modeled", "PUMS", "HTS"],
  { label: "X Axis:", value: "PUMS" })

viewof vYAxis = Inputs.select(
  ["Modeled", "PUMS", "HTS"].filter(d => d !== vXAxis),
  {
    label: "Y Axis:",
    value: ["Modeled", "PUMS", "HTS"].find(d => d !== vXAxis)
  }
)

viewof vDisplay = Inputs.radio(["Households", "Percentages"],
  { label: "Display:", value: "Households" })
hh_filtered_base = hh_data.filter(d =>
  d.calib_run === globalCalibRun && (vINC === "Total" || d.INC === vINC)
)

// Aggregate by geo × HHSIZE × WRKS
hh_agg_facet = {
  const grouped = d3.groups(hh_filtered_base, d => d[vGeoLevel], d => d.HHSIZE, d => d.WRKS);
  const rows = [];
  for (const [geo_id, bySize] of grouped) {
    for (const [hhsize, byWrks] of bySize) {
      for (const [wrks, records] of byWrks) {
        const mod  = d3.sum(records, d => d.Modeled_Count);
        const pums = d3.sum(records, d => d.Observed_Count);
        const hts  = d3.sum(records, d => d.HTS_Count);
        rows.push({
          geo_id, HHSIZE: hhsize, WRKS: wrks,
          Modeled_Count: mod, Observed_Count: pums, HTS_Count: hts,
          Mod_vs_PUMS:  pums > 0 ? (mod - pums) / pums : 0,
          Mod_vs_HTS:   hts  > 0 ? (mod - hts)  / hts  : 0,
          PUMS_vs_Mod:  mod  > 0 ? (pums - mod)  / mod  : 0,
          HTS_vs_Mod:   mod  > 0 ? (hts  - mod)  / mod  : 0,
          PUMS_vs_HTS:  hts  > 0 ? (pums - hts)  / hts  : 0,
          HTS_vs_PUMS:  pums > 0 ? (hts  - pums) / pums : 0,
        });
      }
    }
  }
  // Normalise each source by its own grand total across all geo × size × workers
  const totMod  = d3.sum(rows, d => d.Modeled_Count);
  const totPums = d3.sum(rows, d => d.Observed_Count);
  const totHts  = d3.sum(rows, d => d.HTS_Count);
  return rows.map(r => ({
    ...r,
    Modeled_Pct:  totMod  > 0 ? r.Modeled_Count  / totMod  : 0,
    Observed_Pct: totPums > 0 ? r.Observed_Count  / totPums : 0,
    HTS_Pct:      totHts  > 0 ? r.HTS_Count       / totHts  : 0,
  }));
}

hh_xy_cfg = {
  const fm = {
    "PUMS":    { field: "Observed_Count", pctField: "Observed_Pct", label: "Target (PUMS)", color: themeTarget },
    "HTS":     { field: "HTS_Count",      pctField: "HTS_Pct",      label: "Target (HTS)",  color: themeTargetAlt  },
    "Modeled": { field: "Modeled_Count",  pctField: "Modeled_Pct",  label: "Modeled",          color: themeModel  }
  };
  const errMap = {
    "PUMS|Modeled": "Mod_vs_PUMS",  "PUMS|HTS": "HTS_vs_PUMS",
    "HTS|Modeled":  "Mod_vs_HTS",   "HTS|PUMS": "PUMS_vs_HTS",
    "Modeled|PUMS": "PUMS_vs_Mod",  "Modeled|HTS": "HTS_vs_Mod"
  };
  return { xCfg: fm[vXAxis], yCfg: fm[vYAxis], errField: errMap[`${vXAxis}|${vYAxis}`] };
}

hh_vf = vDisplay === "Percentages"
  ? { xf: hh_xy_cfg.xCfg.pctField, yf: hh_xy_cfg.yCfg.pctField,
      fmt: d3.format(".1%"), tickFmt: d3.format(".0%"), axSuffix: " (%)" }
  : { xf: hh_xy_cfg.xCfg.field,    yf: hh_xy_cfg.yCfg.field,
      fmt: d3.format(",.0f"), tickFmt: undefined, axSuffix: "" }

hh_chart_max = d3.max(hh_agg_facet, d => Math.max(d[hh_vf.xf], d[hh_vf.yf])) * 1.05
  • Scatter Plot
  • Interactive Map
  • Data Table
// 2-D faceted scatter: columns = HH Size, rows = Workers
Plot.plot({
  grid: true,
  width: 900,
  height: 140 * hh_wrks_vals.length + 40,
  marginRight: 60,
  marginLeft: 50,
  fx: { label: "HH Size", tickFormat: d => `Size ${d}`, padding: 0.1 },
  fy: { label: "Workers", tickFormat: d => `${d} Worker${d !== 1 ? "s" : ""}`, padding: 0.1 },
  x: {
    label: `${hh_xy_cfg.xCfg.label}${hh_vf.axSuffix} →`,
    domain: [0, hh_chart_max],
    tickFormat: hh_vf.tickFmt
  },
  y: {
    label: `↑ ${hh_xy_cfg.yCfg.label}${hh_vf.axSuffix}`,
    domain: [0, hh_chart_max],
    tickFormat: hh_vf.tickFmt
  },
  caption: html`<b>${hh_xy_cfg.yCfg.label} vs. ${hh_xy_cfg.xCfg.label}</b> — columns: HH Size &nbsp;|&nbsp; rows: Workers`,
  marks: [
    // 45-degree equal reference line in every facet panel
    Plot.link(
      hh_hhsize_vals.flatMap(s => hh_wrks_vals.map(w => ({ s, w }))),
      {
        x1: 0, y1: 0,
        x2: hh_chart_max, y2: hh_chart_max,
        fx: "s", fy: "w",
        stroke: "gray", strokeOpacity: 0.7, strokeWidth: 1.5
      }
    ),
    Plot.dot(hh_agg_facet, {
      x: hh_vf.xf, y: hh_vf.yf,
      fx: "HHSIZE", fy: "WRKS",
      r: 3.5, fill: hh_xy_cfg.yCfg.color, fillOpacity: 0.6,
      tip: true,
      title: d =>
        `${vGeoLevel}: ${d.geo_id}\nHH Size: ${d.HHSIZE}  Workers: ${d.WRKS}\n` +
        `${hh_xy_cfg.xCfg.label}: ${hh_vf.fmt(d[hh_vf.xf])}\n` +
        `${hh_xy_cfg.yCfg.label}: ${hh_vf.fmt(d[hh_vf.yf])}`
    }),
    Plot.linearRegressionY(hh_agg_facet, {
      x: hh_vf.xf, y: hh_vf.yf,
      fx: "HHSIZE", fy: "WRKS",
      stroke: hh_xy_cfg.yCfg.color, strokeDasharray: "4 4"
    })
  ]
})
function geojsonBoundsHh(fc) {
  let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  const visit = (coords) => {
    if (typeof coords[0] === "number") {
      const [x, y] = coords;
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    } else {
      coords.forEach(visit);
    }
  };
  for (const f of fc.features || []) {
    if (f.geometry) visit(f.geometry.coordinates);
  }
  return minX === Infinity ? null : [[minX, minY], [maxX, maxY]];
}

// A legend/info box rendered as a MapLibre IControl, mirroring the
// L.control({position}) + onAdd-returns-a-div pattern these maps used to
// build their legends under Leaflet.
class HtmlLegendControlHh {
  constructor(html) { this._html = html; }
  onAdd(map) {
    this._map = map;
    this._container = document.createElement("div");
    this._container.className = "maplibregl-ctrl";
    this._container.innerHTML = this._html;
    return this._container;
  }
  onRemove() {
    this._container.parentNode?.removeChild(this._container);
    this._map = undefined;
  }
}

// Basemap style. UGRC's vector tile services (bundled into one merged
// style, report/data/basemap/ugrc-wfrc-lite.json -- a WFRC-tweaked copy
// of UGRC's "lite" style) don't need an API key, unlike the commercial
// basemap these maps used before -- see _quarto.yml's `resources:` entry
// for why that file ships with the site. Path is relative to each
// rendered report page (report/data/basemap/... under docs/), not to
// this .qmd source file.
UGRC_BASEMAP_STYLE_HH = "data/basemap/ugrc-wfrc-lite.json"
UGRC_ATTRIBUTION_HH = "&copy; <a href=\"https://gis.utah.gov/\">Utah AGRC</a>"
{
  const maplibregl = await require("maplibre-gl@5.24.0/dist/maplibre-gl.js");
  if (!document.getElementById("maplibre-css")) {
    const link  = document.createElement("link");
    link.id     = "maplibre-css";
    link.rel    = "stylesheet";
    link.href   = "https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css";
    document.head.appendChild(link);
  }

  const container = html`<div style="height:560px; width:100%; border-radius:6px; overflow:hidden;"></div>`;
  yield container;

  const map = new maplibregl.Map({
    container,
    style: UGRC_BASEMAP_STYLE_HH,
    center: [-111.8910, 40.7608],
    zoom: 9,
    attributionControl: { customAttribution: UGRC_ATTRIBUTION_HH }
  });
  map.addControl(new maplibregl.NavigationControl(), "top-left");

  // Aggregate map data across all HHSIZE × WRKS combinations for selected income filter
  const mapAgg = d3.rollup(
    hh_filtered_base,
    v => {
      const mod  = d3.sum(v, d => d.Modeled_Count);
      const pums = d3.sum(v, d => d.Observed_Count);
      const hts  = d3.sum(v, d => d.HTS_Count);
      return {
        Modeled_Count: mod, Observed_Count: pums, HTS_Count: hts,
        Mod_vs_PUMS:  pums > 0 ? (mod - pums) / pums : null,
        Mod_vs_HTS:   hts  > 0 ? (mod - hts)  / hts  : null,
        PUMS_vs_Mod:  mod  > 0 ? (pums - mod)  / mod  : null,
        HTS_vs_Mod:   mod  > 0 ? (hts  - mod)  / mod  : null,
        PUMS_vs_HTS:  hts  > 0 ? (pums - hts)  / hts  : null,
        HTS_vs_PUMS:  pums > 0 ? (hts  - pums) / pums : null,
      };
    },
    d => d[vGeoLevel]
  );

  const geo_lookup = {
    "CO_FIPS": { geo: geo_county_hh, prop: "CO_FIPS" },
    "PUMA":    { geo: geo_puma_hh,   prop: "PUMA" }
  };
  const { geo: base_geo, prop: name_prop } = geo_lookup[vGeoLevel];

  const colorScale = d3.scaleDiverging(d3.interpolateRdBu).domain([0.5, 0, -0.5]);
  function getColor(pct) {
    if (pct === null || pct === undefined) return "#cccccc";
    return colorScale(Math.max(-0.5, Math.min(0.5, pct)));
  }

  const features = base_geo.features.map((f, i) => {
    const key = f.properties[name_prop];
    const row = mapAgg.get(+key) || mapAgg.get(String(key))
      || { Modeled_Count: 0, Observed_Count: 0, HTS_Count: 0,
           Mod_vs_PUMS: null, Mod_vs_HTS: null,
           PUMS_vs_Mod: null, HTS_vs_Mod: null,
           PUMS_vs_HTS: null, HTS_vs_PUMS: null };
    const err    = row[hh_xy_cfg.errField];
    const noData = err === null || err === undefined;
    const popup =
      `<b>${vGeoLevel} ${key}</b><br>` +
      `<i style="font-size:10px;color:#666">${hh_xy_cfg.yCfg.label} vs. ${hh_xy_cfg.xCfg.label}</i><br>` +
      `Pct Error: ${noData ? "N/A" : (err * 100).toFixed(1) + "%"}<br>` +
      `Modeled: ${d3.format(",.0f")(row.Modeled_Count)}<br>` +
      `Target (PUMS): ${d3.format(",.0f")(row.Observed_Count)}<br>` +
      `Target (HTS): ${d3.format(",.0f")(row.HTS_Count)}`;
    return { ...f, id: i, properties: { ...f.properties, ...row, _color: getColor(err), _popup: popup } };
  });
  const map_features_hh = { type: "FeatureCollection", features };

  let loaded = false;
  let hoveredId = null;

  map.on("load", () => {
    loaded = true;

    map.addSource("hh-choropleth", { type: "geojson", data: map_features_hh });
    map.addLayer({
      id: "hh-choropleth-fill",
      type: "fill",
      source: "hh-choropleth",
      paint: {
        "fill-color": ["get", "_color"],
        "fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.95, 0.72]
      }
    });
    map.addLayer({
      id: "hh-choropleth-outline",
      type: "line",
      source: "hh-choropleth",
      paint: {
        "line-color": "#ffffff",
        "line-width": ["case", ["boolean", ["feature-state", "hover"], false], 2, 1]
      }
    });

    map.on("mousemove", "hh-choropleth-fill", (e) => {
      if (!e.features.length) return;
      const id = e.features[0].id;
      if (id === hoveredId) return;
      if (hoveredId != null) map.setFeatureState({ source: "hh-choropleth", id: hoveredId }, { hover: false });
      hoveredId = id;
      map.setFeatureState({ source: "hh-choropleth", id }, { hover: true });
    });
    map.on("mouseleave", "hh-choropleth-fill", () => {
      if (hoveredId != null) map.setFeatureState({ source: "hh-choropleth", id: hoveredId }, { hover: false });
      hoveredId = null;
    });
    map.on("click", "hh-choropleth-fill", (e) => {
      if (!e.features.length) return;
      new maplibregl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties._popup).addTo(map);
    });

    const bounds = geojsonBoundsHh(map_features_hh);
    if (bounds) map.fitBounds(bounds, { padding: 10 });

    const stops  = [-0.5, -0.25, 0, 0.25, 0.5];
    const labels = ["≤ −50%", "−25%", "0%", "+25%", "≥ +50%"];
    const swatches = stops.map((v, i) =>
      `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">` +
        `<span style="display:inline-block;width:14px;height:14px;border-radius:2px;flex-shrink:0;` +
                     `background:${getColor(v)};border:1px solid rgba(0,0,0,0.15);"></span>` +
        `<span style="font-size:11px;">${labels[i]}</span>` +
      `</div>`
    ).join("");
    const legend_html =
      `<div style="background:white;padding:10px 12px;border-radius:6px;` +
                  `box-shadow:0 1px 5px rgba(0,0,0,0.25);line-height:1.5;">` +
        `<div style="font-size:12px;font-weight:600;margin-bottom:4px;">Percent Error</div>` +
        `<div style="font-size:10px;color:#666;margin-bottom:6px;">` +
          `Red = Underpredict &nbsp;|&nbsp; Blue = Overpredict` +
        `</div>` +
        swatches +
      `</div>`;
    map.addControl(new HtmlLegendControlHh(legend_html), "bottom-right");

    map.resize();
  });

  const resizeObserver = new ResizeObserver(() => {
    if (!loaded) return;
    setTimeout(() => {
      map.resize();
      const bounds = geojsonBoundsHh(map_features_hh);
      if (bounds) map.fitBounds(bounds, { padding: 10 });
    }, 100);
  });
  resizeObserver.observe(container);
  invalidation.then(() => { map.remove(); resizeObserver.disconnect(); });
}
hh_cnt_fmt = vDisplay === "Percentages" ? d3.format(".1%") : d3.format(",.0f")

viewof hh_summary_table = Inputs.table(hh_agg_facet, {
  columns: [
    "geo_id", "HHSIZE", "WRKS",
    vDisplay === "Percentages" ? "Modeled_Pct"  : "Modeled_Count",
    vDisplay === "Percentages" ? "Observed_Pct" : "Observed_Count",
    vDisplay === "Percentages" ? "HTS_Pct"      : "HTS_Count",
    "Mod_vs_PUMS", "Mod_vs_HTS", "PUMS_vs_HTS"
  ],
  header: {
    geo_id:         "Geo ID",
    HHSIZE:         "HH Size",
    WRKS:           "Workers",
    Modeled_Count:  "Modeled",    Modeled_Pct:  "Modeled (%)",
    Observed_Count: "PUMS",       Observed_Pct: "PUMS (%)",
    HTS_Count:      "HTS",        HTS_Pct:      "HTS (%)",
    Mod_vs_PUMS:    "Mod vs PUMS",
    Mod_vs_HTS:     "Mod vs HTS",
    PUMS_vs_HTS:    "PUMS vs HTS"
  },
  format: {
    Modeled_Count:  hh_cnt_fmt, Modeled_Pct:  hh_cnt_fmt,
    Observed_Count: hh_cnt_fmt, Observed_Pct: hh_cnt_fmt,
    HTS_Count:      hh_cnt_fmt, HTS_Pct:      hh_cnt_fmt,
    Mod_vs_PUMS:    d3.format("+.1%"),
    Mod_vs_HTS:     d3.format("+.1%"),
    PUMS_vs_HTS:    d3.format("+.1%")
  },
  sort:    "Mod_vs_PUMS",
  reverse: true,
  rows:    15,
  width:   "100%"
})

Vehicle Ownership

Modeled vs. Target

vo_hhsize_vals = ["1", "2", "3", "4+"]
vo_vehown_vals = ["0", "1", "2", "3+"]
// layout-ncol deliberately omitted: combined with a "panel: input" OJS cell,
// it trips a Quarto 1.8.27 bug (makeSubFigures throws "Cannot read
// properties of undefined (reading 'info')") -- panel: input already flows
// multiple viewof inputs horizontally on its own without it.

// Removed: HH Size and Vehicle Count dropdowns — now used as facet dimensions (cols × rows)
viewof geo_level = Inputs.select(
  ["County", "Super District", "Large District", "Medium District", "Small District", "City"],
  {label: "Geography:", value: "County"})
viewof vo_x_axis = Inputs.select(["Modeled", "ACS", "HTS"],
  { label: "X Axis:", value: "ACS" })

viewof vo_y_axis = Inputs.select(
  ["Modeled", "ACS", "HTS"].filter(d => d !== vo_x_axis),
  {
    label: "Y Axis:",
    value: ["Modeled", "ACS", "HTS"].find(d => d !== vo_x_axis)
  }
)

viewof vo_display = Inputs.radio(["Households", "Percentages"],
  { label: "Display:", value: "Households" })
vo_filtered_base = transpose(val_data_ojs).filter(row =>
  row.GeoLevel === geo_level && row.calib_run === globalCalibRun
)

// Aggregate by GeoName × HHSize × VehOwn
vo_agg_facet = {
  const grouped = d3.groups(vo_filtered_base, d => d.GeoName, d => d.HHSize, d => d.VehOwn);
  const rows = [];
  for (const [GeoName, bySize] of grouped) {
    for (const [hhsize, byVeh] of bySize) {
      for (const [vehown, records] of byVeh) {
        const mod = d3.sum(records, d => d.Model_HH);
        const acs = d3.sum(records, d => d.ACS_HH);
        const hts = d3.sum(records, d => d.HTS_HH);
        rows.push({
          GeoName, HHSize: hhsize, VehOwn: vehown,
          Model_HH: mod, ACS_HH: acs, HTS_HH: hts,
          Pct_Error:      acs > 0 ? (mod - acs) / acs : 0,
          Pct_Error_HTS:  hts > 0 ? (mod - hts) / hts : 0,
          Pct_ACS_vs_Mod: mod > 0 ? (acs - mod) / mod : 0,
          Pct_HTS_vs_Mod: mod > 0 ? (hts - mod) / mod : 0,
          Pct_ACS_vs_HTS: hts > 0 ? (acs - hts) / hts : 0,
          Pct_HTS_vs_ACS: acs > 0 ? (hts - acs) / acs : 0,
        });
      }
    }
  }
  // Normalise each source by its own grand total
  const totMod = d3.sum(rows, d => d.Model_HH);
  const totAcs = d3.sum(rows, d => d.ACS_HH);
  const totHts = d3.sum(rows, d => d.HTS_HH);
  return rows.map(r => ({
    ...r,
    Model_Pct:  totMod > 0 ? r.Model_HH / totMod : 0,
    ACS_Pct:    totAcs > 0 ? r.ACS_HH   / totAcs : 0,
    HTS_Pct_vo: totHts > 0 ? r.HTS_HH   / totHts : 0,
  }));
}

vo_xy_cfg = {
  const fm = {
    "ACS":     { field: "ACS_HH",   pctField: "ACS_Pct",    label: "Target (ACS)", color: themeTarget },
    "HTS":     { field: "HTS_HH",   pctField: "HTS_Pct_vo", label: "Target (HTS)", color: themeTargetAlt   },
    "Modeled": { field: "Model_HH", pctField: "Model_Pct",  label: "Modeled",        color: themeModel    }
  };
  const errMap = {
    "ACS|Modeled": "Pct_Error",       "ACS|HTS":     "Pct_HTS_vs_ACS",
    "HTS|Modeled": "Pct_Error_HTS",   "HTS|ACS":     "Pct_ACS_vs_HTS",
    "Modeled|ACS": "Pct_ACS_vs_Mod",  "Modeled|HTS": "Pct_HTS_vs_Mod"
  };
  return { xCfg: fm[vo_x_axis], yCfg: fm[vo_y_axis], errField: errMap[`${vo_x_axis}|${vo_y_axis}`] };
}

vo_vf = vo_display === "Percentages"
  ? { xf: vo_xy_cfg.xCfg.pctField, yf: vo_xy_cfg.yCfg.pctField,
      fmt: d3.format(".1%"), tickFmt: d3.format(".0%"), axSuffix: " (%)" }
  : { xf: vo_xy_cfg.xCfg.field,    yf: vo_xy_cfg.yCfg.field,
      fmt: d3.format(",.0f"), tickFmt: undefined, axSuffix: "" }

vo_max_val = d3.max(vo_agg_facet, d => Math.max(d[vo_vf.xf], d[vo_vf.yf])) * 1.05
  • Scatter Plot
  • Interactive Map
  • Data Table
// 2-D faceted scatter: columns = HH Size, rows = Vehicle Ownership
Plot.plot({
  grid: true,
  width: 900,
  height: 140 * vo_vehown_vals.length + 40,
  marginRight: 60,
  marginLeft: 50,
  fx: { label: "HH Size",  tickFormat: d => `Size ${d}`, padding: 0.1 },
  fy: { label: "Vehicles", tickFormat: d => `${d} Veh`,  padding: 0.1 },
  x: {
    label: `${vo_xy_cfg.xCfg.label}${vo_vf.axSuffix} →`,
    domain: [0, vo_max_val],
    tickFormat: vo_vf.tickFmt
  },
  y: {
    label: `↑ ${vo_xy_cfg.yCfg.label}${vo_vf.axSuffix}`,
    domain: [0, vo_max_val],
    tickFormat: vo_vf.tickFmt
  },
  caption: html`<b>${vo_xy_cfg.yCfg.label} vs. ${vo_xy_cfg.xCfg.label}</b> — columns: HH Size &nbsp;|&nbsp; rows: Vehicle Ownership`,
  marks: [
    // 45-degree equal reference line in every facet panel
    Plot.link(
      vo_hhsize_vals.flatMap(s => vo_vehown_vals.map(v => ({ s, v }))),
      {
        x1: 0, y1: 0,
        x2: vo_max_val, y2: vo_max_val,
        fx: "s", fy: "v",
        stroke: "gray", strokeOpacity: 0.7, strokeWidth: 1.5
      }
    ),
    Plot.dot(vo_agg_facet, {
      x: vo_vf.xf, y: vo_vf.yf,
      fx: "HHSize", fy: "VehOwn",
      r: 3.5, fill: vo_xy_cfg.yCfg.color, fillOpacity: 0.6,
      tip: true,
      title: d =>
        `${d.GeoName}\nHH Size: ${d.HHSize}  Vehicles: ${d.VehOwn}\n` +
        `${vo_xy_cfg.xCfg.label}: ${vo_vf.fmt(d[vo_vf.xf])}\n` +
        `${vo_xy_cfg.yCfg.label}: ${vo_vf.fmt(d[vo_vf.yf])}`
    }),
    Plot.linearRegressionY(vo_agg_facet, {
      x: vo_vf.xf, y: vo_vf.yf,
      fx: "HHSize", fy: "VehOwn",
      stroke: vo_xy_cfg.yCfg.color, strokeDasharray: "4 4"
    })
  ]
})
{
  const maplibregl = await require("maplibre-gl@5.24.0/dist/maplibre-gl.js");
  if (!document.getElementById("maplibre-css")) {
    const link  = document.createElement("link");
    link.id     = "maplibre-css";
    link.rel    = "stylesheet";
    link.href   = "https://unpkg.com/maplibre-gl@5.24.0/dist/maplibre-gl.css";
    document.head.appendChild(link);
  }

  const container = html`<div style="height:560px; width:100%; border-radius:6px; overflow:hidden;"></div>`;
  yield container;

  const map = new maplibregl.Map({
    container,
    style: UGRC_BASEMAP_STYLE_HH,
    center: [-111.8910, 40.7608],
    zoom: 9,
    attributionControl: { customAttribution: UGRC_ATTRIBUTION_HH }
  });
  map.addControl(new maplibregl.NavigationControl(), "top-left");

  // Aggregate map data across all HHSize × VehOwn combinations for selected geography
  const mapAgg = d3.rollup(
    vo_filtered_base,
    v => {
      const mod = d3.sum(v, d => d.Model_HH);
      const acs = d3.sum(v, d => d.ACS_HH);
      const hts = d3.sum(v, d => d.HTS_HH);
      return {
        Model_HH: mod, ACS_HH: acs, HTS_HH: hts,
        Pct_Error:      acs > 0 ? (mod - acs) / acs : null,
        Pct_Error_HTS:  hts > 0 ? (mod - hts) / hts : null,
        Pct_ACS_vs_Mod: mod > 0 ? (acs - mod) / mod : null,
        Pct_HTS_vs_Mod: mod > 0 ? (hts - mod) / mod : null,
        Pct_ACS_vs_HTS: hts > 0 ? (acs - hts) / hts : null,
        Pct_HTS_vs_ACS: acs > 0 ? (hts - acs) / acs : null,
      };
    },
    d => d.GeoName
  );

  const geo_lookup = {
    "County":          {geo: geo_counties, prop: "County"},
    "Super District":  {geo: geo_super,    prop: "DSUP_NAME"},
    "Large District":  {geo: geo_large,    prop: "DLRG_NAME"},
    "Medium District": {geo: geo_medium,   prop: "DMED_NAME"},
    "Small District":  {geo: geo_small,    prop: "DSML_NAME"},
    "City":            {geo: geo_cities,   prop: "City"},
  };
  const {geo: base_geo, prop: name_prop} = geo_lookup[geo_level];

  const colorScale = d3.scaleDiverging(d3.interpolateRdBu).domain([0.5, 0, -0.5]);
  function getColor(pct) {
    if (pct === null || pct === undefined) return "#cccccc";
    return colorScale(Math.max(-0.5, Math.min(0.5, pct)));
  }

  const features = base_geo.features.map((f, i) => {
    const prop_name = f.properties[name_prop];
    const row = mapAgg.get(prop_name)
      || { Model_HH: 0, ACS_HH: 0, HTS_HH: 0,
           Pct_Error: null, Pct_Error_HTS: null,
           Pct_ACS_vs_Mod: null, Pct_HTS_vs_Mod: null,
           Pct_ACS_vs_HTS: null, Pct_HTS_vs_ACS: null };
    const err    = row[vo_xy_cfg.errField];
    const noData = err === null || err === undefined;
    const popup =
      `<b>${prop_name}</b><br>` +
      `<i style="font-size:10px;color:#666">${vo_xy_cfg.yCfg.label} vs. ${vo_xy_cfg.xCfg.label}</i><br>` +
      `Pct Error: ${noData ? "N/A" : (err * 100).toFixed(1) + "%"}<br>` +
      `Modeled: ${d3.format(",.0f")(row.Model_HH)}<br>` +
      `Target (ACS): ${d3.format(",.0f")(row.ACS_HH)}<br>` +
      `Target (HTS): ${d3.format(",.0f")(row.HTS_HH)}`;
    return { ...f, id: i, properties: { ...f.properties, GeoName: prop_name, ...row, _color: getColor(err), _popup: popup } };
  });
  const map_features_vo = { type: "FeatureCollection", features };

  let loaded = false;
  let hoveredId = null;

  map.on("load", () => {
    loaded = true;

    map.addSource("vo-choropleth", { type: "geojson", data: map_features_vo });
    map.addLayer({
      id: "vo-choropleth-fill",
      type: "fill",
      source: "vo-choropleth",
      paint: {
        "fill-color": ["get", "_color"],
        "fill-opacity": ["case", ["boolean", ["feature-state", "hover"], false], 0.95, 0.72]
      }
    });
    map.addLayer({
      id: "vo-choropleth-outline",
      type: "line",
      source: "vo-choropleth",
      paint: {
        "line-color": "#ffffff",
        "line-width": ["case", ["boolean", ["feature-state", "hover"], false], 2, 1]
      }
    });

    map.on("mousemove", "vo-choropleth-fill", (e) => {
      if (!e.features.length) return;
      const id = e.features[0].id;
      if (id === hoveredId) return;
      if (hoveredId != null) map.setFeatureState({ source: "vo-choropleth", id: hoveredId }, { hover: false });
      hoveredId = id;
      map.setFeatureState({ source: "vo-choropleth", id }, { hover: true });
    });
    map.on("mouseleave", "vo-choropleth-fill", () => {
      if (hoveredId != null) map.setFeatureState({ source: "vo-choropleth", id: hoveredId }, { hover: false });
      hoveredId = null;
    });
    map.on("click", "vo-choropleth-fill", (e) => {
      if (!e.features.length) return;
      new maplibregl.Popup().setLngLat(e.lngLat).setHTML(e.features[0].properties._popup).addTo(map);
    });

    const bounds = geojsonBoundsHh(map_features_vo);
    if (bounds) map.fitBounds(bounds, { padding: 10 });

    const stops  = [-0.5, -0.25, 0, 0.25, 0.5];
    const labels = ["≤ −50%", "−25%", "0%", "+25%", "≥ +50%"];
    const swatches = stops.map((v, i) =>
      `<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">` +
        `<span style="display:inline-block;width:14px;height:14px;border-radius:2px;flex-shrink:0;` +
                     `background:${getColor(v)};border:1px solid rgba(0,0,0,0.15);"></span>` +
        `<span style="font-size:11px;">${labels[i]}</span>` +
      `</div>`
    ).join("");
    const legend_html =
      `<div style="background:white;padding:10px 12px;border-radius:6px;` +
                  `box-shadow:0 1px 5px rgba(0,0,0,0.25);line-height:1.5;">` +
        `<div style="font-size:12px;font-weight:600;margin-bottom:4px;">Percent Error</div>` +
        `<div style="font-size:10px;color:#666;margin-bottom:6px;">` +
          `Red = Underpredict &nbsp;|&nbsp; Blue = Overpredict` +
        `</div>` +
        swatches +
      `</div>`;
    map.addControl(new HtmlLegendControlHh(legend_html), "bottom-right");

    map.resize();
  });

  const resizeObserver = new ResizeObserver(() => {
    if (!loaded) return;
    setTimeout(() => {
      map.resize();
      const bounds = geojsonBoundsHh(map_features_vo);
      if (bounds) map.fitBounds(bounds, { padding: 10 });
    }, 100);
  });
  resizeObserver.observe(container);
  invalidation.then(() => { map.remove(); resizeObserver.disconnect(); });
}
vo_cnt_fmt = vo_display === "Percentages" ? d3.format(".1%") : d3.format(",.0f")

viewof summary_table = Inputs.table(vo_agg_facet, {
  columns: [
    "GeoName", "HHSize", "VehOwn",
    vo_display === "Percentages" ? "Model_Pct"  : "Model_HH",
    vo_display === "Percentages" ? "ACS_Pct"    : "ACS_HH",
    vo_display === "Percentages" ? "HTS_Pct_vo" : "HTS_HH",
    "Pct_Error", "Pct_Error_HTS", "Pct_ACS_vs_HTS"
  ],
  header: {
    GeoName:        "Geography",
    HHSize:         "HH Size",
    VehOwn:         "Vehicles",
    Model_HH:       "Modeled",    Model_Pct:  "Modeled (%)",
    ACS_HH:         "ACS",        ACS_Pct:    "ACS (%)",
    HTS_HH:         "HTS",        HTS_Pct_vo: "HTS (%)",
    Pct_Error:      "Mod vs ACS",
    Pct_Error_HTS:  "Mod vs HTS",
    Pct_ACS_vs_HTS: "ACS vs HTS"
  },
  format: {
    Model_HH:       vo_cnt_fmt, Model_Pct:  vo_cnt_fmt,
    ACS_HH:         vo_cnt_fmt, ACS_Pct:    vo_cnt_fmt,
    HTS_HH:         vo_cnt_fmt, HTS_Pct_vo: vo_cnt_fmt,
    Pct_Error:      d3.format("+.1%"),
    Pct_Error_HTS:  d3.format("+.1%"),
    Pct_ACS_vs_HTS: d3.format("+.1%")
  },
  sort:    "Pct_Error",
  reverse: true,
  rows:    15,
  width:   "100%"
})
TDM Calibration Reports
Trip Generation

© 2025 Wasatch Front Regional Council

  • Contact

  • License