HBW Destination Choice Model
Production & Attraction Pairs by Model
Code
dc = {
const modT = transpose(dist_sum_mod);
const obsT = transpose(dist_sum_obs);
return modT.map(m => {
const o = obsT.find(d =>
d.p_DistLrg === m.p_DistLrg &&
d.a_DistLrg === m.a_DistLrg &&
d.Source === m.Source
);
if (!o || o.total_trips === 0) return null;
return {
p_DistLrg: m.p_DistLrg,
a_DistLrg: m.a_DistLrg,
Source: m.Source,
tmeMod: m.total_trips,
tmeObs: o.total_trips,
tmeErrorPct: (m.total_trips - o.total_trips) / o.total_trips
};
}).filter(d => d !== null);
}
// Source Selection
viewof vSource = Inputs.select(
Array.from(new Set(dc.map(d => d.Source))).sort(),
{ label: "Model Source:" }
)
// Filtered data based on selection
dc_filtered = dc.filter(d => d.Source === vSource)
// Shared Max for the Scatter Plot axes
chartMax = d3.max(dc_filtered, d => Math.max(d.tmeObs, d.tmeMod))Code
// CHART 1a: Model vs Observed Trips
Plot.plot({
grid: true,
width: 460,
height: 380,
marginRight: 50,
caption: html`<h4>1a. Model vs Observed Trips</h4>`,
x: { label: "Observed Trips", domain: [0, chartMax] },
y: { label: "Modeled Trips", domain: [0, chartMax] },
marks: [
Plot.link([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
x1: 0, y1: 0,
x2: (k) => (k <= 1 ? chartMax : chartMax / k),
y2: (k) => (k <= 1 ? chartMax * k : chartMax),
strokeOpacity: (k) => k === 1 ? 1 : 0.2,
stroke: "gray",
strokeWidth: (k) => k === 1 ? 2 : 1
}),
Plot.text([0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4], {
x: (k) => (k <= 1 ? chartMax : chartMax / k),
y: (k) => (k <= 1 ? chartMax * k : chartMax),
text: (k) => k === 1 ? "Equal" : d3.format("+.0%")(k - 1),
textAnchor: "start", dx: 6, fill: "gray", fontSize: 10
}),
Plot.dot(dc_filtered, {
x: "tmeObs",
y: "tmeMod",
r: 3,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
tip: true,
title: (d) =>
`District: ${d.p_DistLrg} to ${d.a_DistLrg}\n` +
`Observed: ${d3.format(",.0f")(d.tmeObs)}\n` +
`Modeled: ${d3.format(",.0f")(d.tmeMod)}`
}),
Plot.linearRegressionY(dc_filtered, {
x: "tmeObs", y: "tmeMod",
stroke: "rgb(80, 116, 230)", strokeDasharray: "4 4"
})
]
})Code
// CHART 1b: Percent Error
Plot.plot({
grid: true,
width: 460,
height: 380,
caption: html`<h4>1b. Model vs Observed Percent Error</h4>`,
x: { label: "Observed Trips", domain: [0, chartMax] },
y: { label: "Percent Error", domain: [-2, 2], tickFormat: d3.format(".0%") },
marks: [
Plot.ruleY([0], { stroke: "#000", strokeWidth: 1.5 }),
Plot.dot(dc_filtered, {
x: "tmeObs",
y: "tmeErrorPct",
r: 3,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
tip: true,
title: (d) =>
`District: ${d.p_DistLrg} to ${d.a_DistLrg}\n` +
`Observed: ${d3.format(",.0f")(d.tmeObs)}\n` +
`Error: ${d3.format("+.1%")(d.tmeErrorPct)}`
}),
Plot.line([[0, 1.0], [500, 1.0], [500, 0.5], [1000, 0.5], [1000, 0.25], [chartMax, 0.25]],
{ stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" }),
Plot.line([[0, -1.0], [500, -1.0], [500, -0.5], [1000, -0.5], [1000, -0.25], [chartMax, -0.25]],
{ stroke: "gray", strokeWidth: 1.5, strokeDasharray: "2 2", curve: "step-after" })
]
})Production & Attraction Pairs by Auto Ownership & Income
Code
dc_2 = {
const modT2 = transpose(dist_sum_mod2);
const obsT2 = transpose(dist_sum_obs2);
return modT2.map(m => {
const o = obsT2.find(d =>
d.p_DistLrg === m.p_DistLrg &&
d.a_DistLrg === m.a_DistLrg &&
d.veh_inc === m.veh_inc
);
if (!o || o.total_trips === 0) return null;
// Parse "HBW_0veh_lo" → veh="0veh", inc="lo"
const parts = m.veh_inc.replace("HBW_", "").split("_");
const veh = parts[0]; // "0veh" | "1veh" | "2veh"
const inc = parts[1]; // "lo" | "hi"
return {
p_DistLrg: m.p_DistLrg,
a_DistLrg: m.a_DistLrg,
veh_inc: m.veh_inc,
veh,
inc,
Source: m.Source,
tmeMod: m.total_trips,
tmeObs: o.total_trips,
tmeErrorPct: (m.total_trips - o.total_trips) / o.total_trips
};
}).filter(d => d !== null);
}
// Stable sorted facet dimension lists
// Explicit order: Gravity first (left), Destination Choice second (right)
source_vals_2 = ["Gravity", "Destination Choice"]
veh_vals = ["0veh", "1veh", "2veh"]Code
Code
dc_2_filtered = dc_2.filter(d => d.inc === vInc_2)
// Axis max from income-filtered data
chartMax_2 = d3.max(dc_2_filtered, d => Math.max(d.tmeObs, d.tmeMod)) * 1.05
// 2-D FACETED SCATTER: columns = Source, rows = auto count
Plot.plot({
grid: true,
width: 900,
height: 300 * veh_vals.length + 60,
marginRight: 80,
marginLeft: 110,
marginTop: 50,
marginBottom: 50,
style: { fontSize: "15px" },
fx: { label: "Model Source", domain: source_vals_2, padding: 0.1 },
fy: { label: "Households", domain: veh_vals, tickFormat: d => d.replace("veh", "-vehicle").replace("2-vehicle", "2+-vehicle"), padding: 0.1 },
x: { label: "Observed Trips →", domain: [0, chartMax_2], labelOffset: 40 },
y: { label: "↑ Modeled Trips", domain: [0, chartMax_2], labelOffset: 55 },
caption: html`<b>Modeled vs. Observed — ${vInc_2 === "lo" ? "Low-Income" : "Not Low-Income"}</b> | columns: Model | rows: Auto Ownership Category`,
marks: [
// 45-degree reference line in every panel
Plot.link(
source_vals_2.flatMap(s => veh_vals.map(v => ({ s, v }))),
{
x1: 0, y1: 0,
x2: chartMax_2, y2: chartMax_2,
fx: "s", fy: "v",
stroke: "gray", strokeOpacity: 0.7, strokeWidth: 1.5
}
),
// Data points
Plot.dot(dc_2_filtered, {
x: "tmeObs",
y: "tmeMod",
fx: "Source",
fy: "veh",
r: 3,
fill: "rgb(80, 116, 230)",
fillOpacity: 0.5,
tip: true,
title: d =>
`District: ${d.p_DistLrg} → ${d.a_DistLrg}\n` +
`Source: ${d.Source} | Auto Ownership: ${d.veh} | Income: ${d.inc}\n` +
`Observed: ${d3.format(",.0f")(d.tmeObs)}\n` +
`Modeled: ${d3.format(",.0f")(d.tmeMod)}`
}),
// Forced-through-origin regression line per panel
Plot.link(
source_vals_2.flatMap(s => veh_vals.map(v => {
const subset = dc_2_filtered.filter(d => d.Source === s && d.veh === v);
const denom = d3.sum(subset, d => d.tmeObs ** 2);
const slope = denom === 0 ? 0 : d3.sum(subset, d => d.tmeObs * d.tmeMod) / denom;
return { s, v, slope };
})),
{
x1: 0, y1: 0,
x2: chartMax_2,
y2: d => d.slope * chartMax_2,
fx: "s", fy: "v",
stroke: "rgb(80, 116, 230)",
strokeDasharray: "4 4",
strokeWidth: 2
}
)
]
})