mutable sharedCountries = []
mutable sharedTransactionType = "Cash-out via agent"
mutable sharedTransactionAmount = 100
mutable sharedProviderTypes = []
html`<span>Use our reference values </span>
<label class="switch">
<input type="checkbox">
<span class="slider round"></span>
</label>
<span> Use unique values from $1 to $1,000</span>`Pricing Trends Over Time
Changes in DFS prices are influenced by several key factors, including regulatory and policy changes, market competition and operational costs.
The visualization shows the changes in average prices by country and transaction type. Prices are expressed as percentages of the transaction amount. For each country, the values represent a simple average of all providers.
Depending on the selected option, the prices shown reflect either the cost of completing a transaction equal to a reference value, or a user-specified amount ranging from $1 to $1,000. Pre-specified reference values vary by country, while user-specified values use the same USD value across all countries. For more information on reference values, see our methodology.
Oanda.com was used to convert the values from local currency to USD. This was accessed on December 30, 2024.
DATES_TO_REMAP_TO_OCTOBER = [
// September beta dates
20250922,
20250923,
20250924,
20250925,
20250926,
20250929,
20250930,
// October expanded dates
20251001,
20251002,
20251006,
20251008,
20251029
];
// Helper function to convert Date object to YYYYMMDD number format
function dateToYYYYMMDD(dateValue) {
if (dateValue instanceof Date) {
const year = dateValue.getFullYear();
const month = String(dateValue.getMonth() + 1).padStart(2, '0');
const day = String(dateValue.getDate()).padStart(2, '0');
return parseInt(`${year}${month}${day}`);
} else {
return dateValue;
}
}
// Query and process summary data
summary_data_raw = {
yield dfsdb.query("SELECT * FROM summary_table WHERE transaction_value = 'high'");
}
// Helper function for numeric validation
isNumeric = (str) => /^[+-]?\d+(\.\d+)?$/.test(str);
// Helper function to fix provider type capitalization
fixProviderTypeCapitalization = (providerType) => {
const fixes = {
'mobile banking': 'Mobile banking',
'mobile money': 'Mobile money',
'other types of fsps': 'Other types of FSPs'
};
return fixes[providerType] || providerType;
};
// Convert "NA" strings to JavaScript NaN and ensure type safety
summary_data_processed = {
yield summary_data_raw.map(row => {
Object.keys(row).forEach(key => {
if (row[key] === "NA")
row[key] = NaN;
else if (isNumeric(row[key]))
row[key] = parseFloat(row[key]);
});
return row;
});
}
// Add month column using plain JavaScript
summary_data_objects = {
yield summary_data_processed.map(d => ({
...d,
month: d.date_collection ?
d.date_collection.toString().slice(0, 4) + "-" + d.date_collection.toString().slice(4, 6) :
null
}));
}// Group and calculate averages manually
prepped_data = {
yield (() => {
const groups = {};
// Group by country and month, filtering by provider type
for (const row of summary_data_objects) {
// Skip rows that don't match selected provider types
if (!providerTypes.includes(fixProviderTypeCapitalization(row.fsp_type))) continue;
const key = `${row.country}|${row.month}`;
if (!groups[key]) {
groups[key] = {
country: row.country,
month: row.month,
rows: []
};
}
groups[key].rows.push(row);
}
// Calculate averages for each group
const results = [];
for (const group of Object.values(groups)) {
const result = {
country: group.country,
month: group.month
};
// Process each fee column (using actual CSV column names)
const columns = [
'pct_cost_cash-in via agent',
'pct_cost_cash-in via atm',
'pct_cost_p2p on-network transfer',
'pct_cost_p2p off-network transfer',
'pct_cost_p2p to unregistered user',
'pct_cost_bank to wallet',
'pct_cost_wallet to bank',
'pct_cost_payment at merchant',
'pct_cost_utility payment',
'pct_cost_cash-out via agent',
'pct_cost_cash-out via atm'
];
for (const col of columns) {
const values = group.rows.map(r => r[col]).filter(v => v != null);
const validVals = values.filter(v => v >= 0); // Keep current time series filtering
let avgValue;
if (validVals.length === 0) {
avgValue = null;
} else {
avgValue = 100 * (validVals.reduce((a, b) => {
if (isNaN(a) || isNaN(b)) return isNaN(a) ? b : a;
return a + b;
}) / validVals.length);
}
// Convert column name to expected format for visualization
const newColName = col.replace('pct_cost_', 'avg_cost_').replace(/[\s-]/g, '_');
result[newColName] = avgValue;
}
results.push(result);
}
return results;
})();
}import {prettyName} from "/assets/js/utilities.js"
import {primaryPalette as palette} from "/assets/js/colors.js"
reference_data = prepped_data
// Helper function to create UTC dates from YYYY-MM strings
parseMonthToUTC = (monthStr) => {
if (!monthStr) return null;
const [year, month] = monthStr.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, 1)); // First day of the month in UTC
}
// Transform reference_data into long format for plotting
data_long = reference_data.flatMap(row => [
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-in via ATM",
value: row.avg_cost_cash_in_via_atm
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "On-net transfer",
value: row.avg_cost_p2p_on_network_transfer
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Wallet-to-bank transfer",
value: row.avg_cost_wallet_to_bank
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Merchant payment",
value: row.avg_cost_payment_at_merchant
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-out via agent",
value: row.avg_cost_cash_out_via_agent
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Bank-to-wallet transfer",
value: row.avg_cost_bank_to_wallet
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-in via agent",
value: row.avg_cost_cash_in_via_agent
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Cash-out via ATM",
value: row.avg_cost_cash_out_via_atm
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Transfer to unregistered user",
value: row.avg_cost_p2p_to_unregistered_user
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Off-net transfer",
value: row.avg_cost_p2p_off_network_transfer
},
{
country: prettyName(row.country),
month: parseMonthToUTC(row.month),
category: "Utility payment",
value: row.avg_cost_utility_payment
}
])
// Extract category values from the reference_data and sort in reverse chronological order
uniqueCategory = [...new Set(data_long.map(prices => prices.category))].sort()
// Extract country values from the reference_data and sort
uniqueCountry = [...new Set(data_long.map(prices => prices.country))].sort()
// Get unique provider types from the summary data with fixed capitalization
uniqueProviderTypes = [...new Set(summary_data_objects.map(d => fixProviderTypeCapitalization(d.fsp_type)))].filter(d => d != null).sort()
// Initialize shared state if empty
{
if (sharedCountries.length === 0) {
mutable sharedCountries = uniqueCountry;
}
if (sharedProviderTypes.length === 0 && uniqueProviderTypes.length > 0) {
mutable sharedProviderTypes = uniqueProviderTypes;
}
return html``;
}viewof category = {
const input = Inputs.select(
uniqueCategory,
{ value: sharedTransactionType,
label: "Transaction Type:",
multiple: false,
sort: false,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedTransactionType = input.value;
});
return input;
}
//| panel: input
viewof country = {
const input = Inputs.checkbox(
uniqueCountry,
{
value: sharedCountries,
label: "Countries:",
multiple: true,
sort: true,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedCountries = input.value;
});
return input;
}
//| panel: input
viewof providerTypes = {
const input = Inputs.checkbox(
uniqueProviderTypes,
{
value: sharedProviderTypes,
label: "Provider Types:",
multiple: true,
sort: true,
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedProviderTypes = input.value;
});
return input;
}filtered_reference_raw = data_long
.filter(function(prices) {
return (prices.category === category &&
country.includes(prices.country) &&
prices.value != null &&
!isNaN(prices.value) &&
prices.value >= 0);
})
.sort((a, b) => {
// Sort by country first, then by month to ensure consistent line drawing
if (a.country !== b.country) {
return a.country.localeCompare(b.country);
}
return a.month.getTime() - b.month.getTime();
});
// Add line breaks at September 2, 2025
filtered_reference = (() => {
const cutoffDate = new Date('2025-09-02');
const dataWithBreaks = [];
// Group by country
const countryGroups = d3.group(filtered_reference_raw, d => d.country);
countryGroups.forEach((countryData, country) => {
const sorted = countryData.sort((a, b) => a.month.getTime() - b.month.getTime());
const before = sorted.filter(d => d.month < cutoffDate);
const after = sorted.filter(d => d.month >= cutoffDate);
// Add data before cutoff
dataWithBreaks.push(...before);
// Insert break point if there's data both before and after
if (before.length > 0 && after.length > 0) {
dataWithBreaks.push({
country: country,
month: cutoffDate,
category: category,
value: null
});
}
// Add data after cutoff
dataWithBreaks.push(...after);
});
return dataWithBreaks;
})();dataCountries = [...new Set(data_long.map(d => d.country))]
uniqueMonths = [...new Set(data_long.map(d => d.month.getTime()))].map(t => new Date(t));
// Get countries with technical issues (-55 values) in the raw reference_data
countriesWithTechnicalIssues = ["Pakistan"] // Directly include Pakistan which is known to have technical issues
// Find countries that have missing data in ANY month for the selected category
countriesWithAnyMissingData = dataCountries.filter(country =>
uniqueMonths.some(month =>
!data_long.some(d =>
d.country === country &&
d.category === category &&
d.month.getTime() === month.getTime() &&
d.value != null &&
!isNaN(d.value)
)
) && !countriesWithTechnicalIssues.includes(country)
)
// Create combined footnote text with both notes and country listings
footnoteText = html`
<div style="margin-top: 15px; font-size: 14px; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol style="padding-left: 20px; margin-top: 5px;">
<li>
${countriesWithAnyMissingData.length > 0 ? html`<span style="font-style: italic;">Countries with missing fees for ${category} for some or all months : ${countriesWithAnyMissingData.join(", ")}</span><br/>` : ''}
Fees labeled "not listed" are not clearly available on provider websites. This may indicate the transaction is not offered by the provider, or the transaction is offered but lacks clear pricing on the website. In these cases, prices may be missing, ambiguous, inconsistent, or described only as "varies."
</li>
<li>
${countriesWithTechnicalIssues.length > 0 ? html`<span style="font-style: italic;">Countries with unavailable fees for ${category} due to technical issues for some or all months: ${countriesWithTechnicalIssues.join(", ")}</span><br/>` : ''}
Fees labeled as "unavailable due to technical issues" are available on provider websites, but IPA was unable to collect this information due to technical difficulties with our data collection process.
</li>
<li>
Simple averages by country presented, restricted to the selected provider type(s): ${providerTypes.join(", ")}.
</li>
<li>
The beta version of data collection, limited to a subset of providers and countries, ran through September 2025. In October 2025, coverage expanded to include all providers and countries. Differences in reported fees between those months are therefore primarily due to this rollout, not real-world price changes.</li>
</ol>
</div>`
// Empty placeholders for sections that will no longer be used
technicalIssuesSection = html``;
notListedSection = html``;
html`<div>
${Plot.plot({
marks: [
Plot.ruleY([0]),
Plot.line(filtered_reference, {
x: "month",
y: "value",
stroke: "country",
strokeWidth: 2,
marker: null, // Remove default markers from the line
z: "country", // Explicitly group lines by country to suppress warning and maintain continuity
}),
// Add separate point marks
Plot.dot(filtered_reference, {
x: "month",
y: "value",
stroke: "country",
fill: "country",
z: "country", // Explicitly specify z channel to suppress high cardinality warning
r: 4,
channels: {
Country: "country",
"Transaction type": "category",
"Fee (%)": d => d.value != null ? d.value.toFixed(2) + "%" : "N/A",
},
tip: {
format: {
y: false,
x: false,
stroke: false,
fill: false,
}
}
}),
],
x: {
label: "",
type: "time", // Ensure the x-axis is treated as a time scale
ticks: d3.utcMonth.every(1), // Show one tick per month
tickFormat: d3.utcFormat("%Y-%b"),
},
y: {
label: "Cost (% of transaction amount)",
grid: true,
tickFormat: d => d + "%",
nice:true
},
color: {
legend: true,
domain: uniqueCountry, // List of all countries
range: palette
},
width: 800,
height: 400
})}
${footnoteText}
</div>`// from https://observablehq.com/@jeremiak/download-data-button
reference_data_button = (reference_data, filename = 'reference_data.csv') => {
if (!reference_data) throw new Error('Array of data required as first argument');
let downloadData;
if (filename.includes('.csv')) {
downloadData = new Blob([d3.csvFormat(reference_data)], { type: "text/csv" });
} else {
downloadData = new Blob([JSON.stringify(reference_data, null, 2)], {
type: "application/json"
});
}
const size = (downloadData.size / 1024).toFixed(0);
const button = DOM.download(
downloadData,
filename,
`Download Data (~${size} KB)`
);
return button;
}exploded_dfsdb = DuckDBClient.of({
all_prices: FileAttachment("data/all_listed_prices.csv")
})
// Create the exploded dataset using DuckDB
exploded_data = {
try {
// First, load the CSV into DuckDB
await exploded_dfsdb.query(`
CREATE TABLE IF NOT EXISTS raw_prices AS
SELECT * FROM all_prices
`);
// Load the SQL query from file
const sqlQuery = await FileAttachment("src/sql/data_explosion.sql").text()
// Run the main exploded data query
const result = await exploded_dfsdb.query(sqlQuery);
return result;
} catch (error) {
throw error;
}
}
// Track loading state - starts as false (not ready)
exploded_data_ready = {
try {
await exploded_data;
// Hide the overlay loading spinner when data is ready
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
return true;
} catch (error) {
// Hide overlay on error too
const loadingOverlay = document.getElementById('loading-overlay');
if (loadingOverlay) {
loadingOverlay.style.display = 'none';
}
return false;
}
}
// Filter and process exploded data with NA handling
transaction_data = exploded_data
.filter(d => d.usd_fee != null && d.usd_transaction_amount != null)
.map((d, index) => {
return {
...d,
// Convert NA strings to NaN and ensure numeric types
usd_fee: d.usd_fee === "NA" ? NaN : (typeof d.usd_fee === 'string' ? parseFloat(d.usd_fee) : d.usd_fee),
fee_pct_of_transaction: d.fee_pct_of_transaction === "NA" ? NaN : (typeof d.fee_pct_of_transaction === 'string' ? parseFloat(d.fee_pct_of_transaction) : d.fee_pct_of_transaction),
country: d.country.replace(/\b\w/g, l => l.toUpperCase()), // Title case
actual_date_collection: d.date_collection,
date_collection: (() => {
const dateNum = dateToYYYYMMDD(d.date_collection);
// Apply hardcoded date logic using constants
if (DATES_TO_REMAP_TO_OCTOBER.includes(dateNum)) return 20251001;
return dateNum; // fallback to collection_date
})(),
month: (() => {
const dateNum = dateToYYYYMMDD(d.date_collection);
// Apply hardcoded date logic using constants
let dc = dateNum;
if (DATES_TO_REMAP_TO_OCTOBER.includes(dc)) {
dc = 20251001;
}
if (dc != null) {
const dcStr = dc.toString();
if (dcStr.length >= 6) {
const year = dcStr.substring(0, 4);
const month = dcStr.substring(4, 6);
return `${year}-${month}`;
}
}
return d.month;
})()
}})
fees_data = transaction_datadata = exploded_data_ready ? fees_data : []
// Extract unique values from data for filter options
uniqueTransactionTypes = [...new Set(data.map(d => d.transaction_type))].sort()
uniqueCountries = [...new Set(data.map(d => d.country))].sort()
uniqueProviderTypesUnique = [...new Set(data.map(d => fixProviderTypeCapitalization(d.fsp_type)))].filter(d => d != null).sort()
//| panel: input
// Slider control for selecting transaction amount ($1-$1000)
viewof transactionAmount = {
const input = Inputs.range(
[1, 1000],
{
value: sharedTransactionAmount,
step: 1, // Allow $1 increments
label: "Transaction Amount (USD):"
}
);
const slider = input.querySelector('input[type="range"]');
const textBox = input.querySelector('input[type="number"]');
// Set initial value in text box
if (textBox) textBox.value = slider.value;
// Update text box in real-time but only update state on change
slider.addEventListener('input', () => {
if (textBox) textBox.value = slider.value;
});
// Only update shared state when user finishes dragging
slider.addEventListener('change', () => {
mutable sharedTransactionAmount = parseInt(slider.value);
});
if (textBox) {
textBox.addEventListener('input', () => {
slider.value = textBox.value;
mutable sharedTransactionAmount = parseInt(textBox.value);
});
}
return input;
}
//| panel: input
// Transaction type dropdown
viewof transactionType = {
const input = Inputs.select(
uniqueTransactionTypes,
{
value: sharedTransactionType,
label: "Transaction Type:",
multiple: false, // Single selection only
sort: false, // Keep original sort order
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedTransactionType = input.value;
});
return input;
}
//| panel: input
// Checkbox group for selecting countries to display
viewof selectedCountries = {
const input = Inputs.checkbox(
uniqueCountries,
{
value: sharedCountries.filter(c => uniqueCountries.includes(c)), // Use shared countries that exist in unique data
label: "Countries:",
multiple: true, // Allow multiple selections
sort: true, // Sort alphabetically
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedCountries = input.value;
});
return input;
}
//| panel: input
// Checkbox group for selecting provider types to display
viewof selectedProviderTypes = {
const input = Inputs.checkbox(
uniqueProviderTypesUnique,
{
value: sharedProviderTypes.filter(pt => uniqueProviderTypesUnique.includes(pt)), // Use shared provider types that exist in unique data
label: "Provider Types:",
multiple: true, // Allow multiple selections
sort: true, // Sort alphabetically
unique: true
}
);
input.addEventListener('input', () => {
mutable sharedProviderTypes = input.value;
});
return input;
}filtered = data.filter(function(d) {
return (d.usd_transaction_amount === transactionAmount && // Exact match on slider value
d.transaction_type === transactionType && // Match selected transaction type
selectedCountries.includes(d.country) && // Include only selected countries
selectedProviderTypes.includes(fixProviderTypeCapitalization(d.fsp_type))); // Include only selected provider types
}); // Remove the duplicate remapping - it should already be handled in the initial data processing
// Group filtered data by country and date, then calculate average fees
// This handles cases where multiple providers exist for the same country/date
groupedData = d3.rollup(
filtered,
v => d3.mean(v, d => d.fee_pct_of_transaction), // Calculate mean fee percentage for each group
d => d.country, // Group by country
d => d.date_collection // Then by date (now with consolidated October dates)
)
// Transform the nested grouped data into a flat array suitable for plotting
plotDataRaw = Array.from(groupedData, ([country, dates]) =>
Array.from(dates, ([date, avgFeePercent]) => {
// Convert date number to Date object
let finalDate;
if (date instanceof Date) {
finalDate = date;
} else {
// Convert YYYYMMDD number to Date object using UTC to avoid timezone issues
const dateStr = date.toString();
if (dateStr.length >= 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6));
const day = parseInt(dateStr.substring(6, 8));
finalDate = new Date(Date.UTC(year, month - 1, day)); // Use UTC to avoid timezone conversion
} else {
finalDate = new Date(date);
}
}
return {
country: country,
date: finalDate,
feePercent: avgFeePercent
};
})
).flat() // Flatten the nested array structure
.filter(d => d.feePercent != null && !isNaN(d.feePercent) && d.feePercent >= 0) // Filter out invalid values
.sort((a, b) => {
// Sort by country first, then by date to ensure consistent line drawing
if (a.country !== b.country) {
return a.country.localeCompare(b.country);
}
return a.date.getTime() - b.date.getTime();
})
// Add line breaks at September 2, 2025
plotData = (() => {
const cutoffDate = new Date(Date.UTC(2025, 8, 2)); // September 2, 2025 in UTC (month is 0-indexed)
const dataWithBreaks = [];
// Group by country
const countryGroups = d3.group(plotDataRaw, d => d.country);
countryGroups.forEach((countryData, country) => {
const sorted = countryData.sort((a, b) => a.date.getTime() - b.date.getTime());
const before = sorted.filter(d => d.date < cutoffDate);
const after = sorted.filter(d => d.date >= cutoffDate);
// Add data before cutoff
dataWithBreaks.push(...before);
// Insert break point if there's data both before and after
if (before.length > 0 && after.length > 0) {
dataWithBreaks.push({
country: country,
date: cutoffDate,
feePercent: null
});
}
// Add data after cutoff
dataWithBreaks.push(...after);
});
return dataWithBreaks;
})();html`<div id="loading-overlay" class="loading-overlay">
<div class="spinner"></div>
<div class="loading-text">Processing Pricing Data</div>
<div class="loading-subtext">Generating interactive fee calculations for all transaction amounts from $1 to $1,000.<br/>This intensive process may take a moment and temporarily freeze the page.</div>
</div>`dataCountriesUnique = [...new Set(data.map(d => d.country))]
uniqueDatesUnique = [...new Set(data.map(d => d.date_collection))].map(dateNum => {
// Convert YYYYMMDD number to Date object using UTC
const dateStr = dateNum.toString();
if (dateStr.length >= 8) {
const year = parseInt(dateStr.substring(0, 4));
const month = parseInt(dateStr.substring(4, 6));
const day = parseInt(dateStr.substring(6, 8));
return new Date(Date.UTC(year, month - 1, day)); // Use UTC to avoid timezone conversion
}
return new Date(dateNum);
});
// Get countries with technical issues (hardcoded based on known issues)
countriesWithTechnicalIssuesUnique = ["Pakistan"] // Directly include Pakistan which is known to have technical issues
// Find countries that have missing data in ANY date for the selected transaction type and amount
countriesWithAnyMissingDataUnique = dataCountriesUnique.filter(country =>
uniqueDatesUnique.some(date =>
!data.some(d =>
d.country === country &&
d.transaction_type === transactionType &&
d.usd_transaction_amount === transactionAmount &&
d.date_collection === dateToYYYYMMDD(date) &&
d.usd_fee != null &&
!isNaN(d.usd_fee)
)
) && !countriesWithTechnicalIssuesUnique.includes(country)
)
// Create combined footnote text with both notes and country listings
footnoteTextUnique = html`
<div style="margin-top: 15px; font-size: 14px; max-width: 800px;">
<p><strong>Notes:</strong></p>
<ol style="padding-left: 20px; margin-top: 5px;">
<li>
${countriesWithAnyMissingDataUnique.length > 0 ? html`<span style="font-style: italic;">Countries with missing fees for ${transactionType} at $${transactionAmount} for some or all months: ${countriesWithAnyMissingDataUnique.join(", ")}</span><br/>` : ''}
Fees labeled "not listed" are not clearly available on provider websites. This may indicate the transaction is not offered by the provider, or the transaction is offered but lacks clear pricing on the website. In these cases, prices may be missing, ambiguous, inconsistent, or described only as "varies."
</li>
<li>
${countriesWithTechnicalIssuesUnique.length > 0 ? html`<span style="font-style: italic;">Countries with unavailable fees for ${transactionType} at $${transactionAmount} due to technical issues for some or all months: ${countriesWithTechnicalIssuesUnique.join(", ")}</span><br/>` : ''}
Fees labeled as "unavailable due to technical issues" are available on provider websites, but IPA was unable to collect this information due to technical difficulties with our data collection process.
</li>
<li>
Simple averages by country presented, restricted to the selected provider type(s): ${selectedProviderTypes.join(", ")}.
</li>
<li>
The beta version of data collection, limited to a subset of providers and countries, ran through September 2025. In October 2025, coverage expanded to include all providers and countries. Differences in reported fees between those months are therefore primarily due to this rollout, not real-world price changes.</li>
</ol>
</div>`
// Create the time series visualization
html`<div>
${plotData.length === 0 ?
// Show helpful message when no data matches the current filters
html`<div style="padding: 20px; text-align: center; color: #666;">
No data available for the selected filters. Try adjusting the transaction amount, type, or countries.
</div>` :
// Create the main plot when data is available
Plot.plot({
marks: [
// Add horizontal line at y=0 for reference
Plot.ruleY([0]),
// Draw connected lines for each country over time
Plot.line(plotData, {
x: "date",
y: "feePercent",
stroke: "country", // Color lines by country
strokeWidth: 2,
marker: null, // No markers on the line itself
z: "country", // Explicitly group lines by country to suppress warning and maintain continuity
}),
// Add interactive dots at each data point with alphabetically organized jitter on x-dimension
Plot.dot(plotData, {
x: "date",
y: "feePercent",
stroke: "country",
fill: "country",
z: "country", // Explicitly specify z channel to suppress high cardinality warning
r: 4, // 4px radius dots
channels: { // Custom tooltip content
Country: "country",
Date: d => d.date.toISOString().split('T')[0],
"Fee (%)": d => d.feePercent != null ? d.feePercent.toFixed(2) + "%" : "N/A",
},
tip: {
format: {
y: false, // Don't show default y value
x: false, // Don't show default x value
stroke: false, // Don't show stroke color
fill: false, // Don't show fill color
}
}
}),
],
x: {
label: "Date",
type: "time", // Treat x-axis as time scale
ticks: d3.utcMonth.every(1), // One tick per month
tickFormat: d3.utcFormat("%Y-%b"), // Format as YYYY-MON
},
y: {
label: "Cost (% of transaction amount)",
grid: true, // Show horizontal grid lines
tickFormat: d => d + "%",
nice:true
},
color: {
legend: true, // Show color legend
domain: uniqueCountries, // All possible countries
range: palette // Use consistent color palette
},
width: 800,
height: 400
})
}
${footnoteTextUnique}
</div>`Inputs.table(
plotData.filter(d => d.feePercent != null).map(d => ({
Country: d.country,
Date: d.date.toISOString().split('T')[0], // Format date as YYYY-MM-DD
"Fee (%)": d.feePercent.toFixed(2), // Round fee percentage to 2 decimal places
"Transaction Amount (USD)": transactionAmount, // Include current filter values
"Transaction Type": transactionType
})),
{select: false} // Disable row selection
)// Reusable download button function (adapted from pricing-over-time.qmd)
button = (data, filename = 'data.csv') => {
if (!data) throw new Error('Array of data required as first argument');
let downloadData;
if (filename.includes('.csv')) {
// Format data as CSV for download
downloadData = new Blob([d3.csvFormat(data)], { type: "text/csv" });
} else {
// Format data as JSON for download
downloadData = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json"
});
}
// Calculate and display file size in KB
const size = (downloadData.size / 1024).toFixed(0);
// Create and return the download button element
const button = DOM.download(
downloadData,
filename,
`Download Data (~${size} KB)`
);
return button;
}// Wait for DOM to load
{
setTimeout(() => {
// Select the first toggle checkbox in the custom switch
const checkbox = document.querySelector('label.switch input[type="checkbox"]');
if (!checkbox) return;
function toggleDisplay() {
const referenceDataElems = document.querySelectorAll('.reference-data');
const uniqueValuesElems = document.querySelectorAll('.unique-values');
if (checkbox.checked) {
referenceDataElems.forEach(el => el.style.display = 'none');
uniqueValuesElems.forEach(el => el.style.display = '');
} else {
referenceDataElems.forEach(el => el.style.display = '');
uniqueValuesElems.forEach(el => el.style.display = 'none');
}
}
// Initial state
toggleDisplay();
// Listen for changes
checkbox.addEventListener('change', toggleDisplay);
}, 0);
return html``;
}