Code
d3 = require("d3@7")
topojson = require("topojson-client@3")
// Your location data by year
locationData = [
{ year: 1998, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 1999, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 2000, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 2001, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 2002, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 2003, location: "Racine", country: "USA", lat: 42.7261, lon: -87.7828 },
{ year: 2004, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2005, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2006, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2007, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2008, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2009, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2010, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2011, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2012, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2013, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2014, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2015, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2016, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2017, location: "Reutlingen", country: "Germany", lat: 48.4921, lon: 9.2095 },
{ year: 2018, location: "Stirling", country: "Scotland", lat: 56.1165, lon: -3.9369 },
{ year: 2019, location: "Stirling", country: "Scotland", lat: 56.1165, lon: -3.9369 },
{ year: 2020, location: "Stirling", country: "Scotland", lat: 56.1165, lon: -3.9369 },
{ year: 2021, location: "Stirling", country: "Scotland", lat: 56.1165, lon: -3.9369 },
{ year: 2022, location: "Royal Leamington Spa", country: "England", lat: 52.2851, lon: -1.5364 },
{ year: 2023, location: "Bath", country: "England", lat: 51.3751, lon: -2.3618 },
{ year: 2024, location: "Irvine", country: "USA", lat: 33.6846, lon: -117.8265 },
{ year: 2025, location: "Irvine", country: "USA", lat: 33.6846, lon: -117.8265 }
]
// Load world map data
world = {
const response = await fetch("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json");
return topojson.feature(await response.json(), "countries");
}
// Function to get current theme colors for timeline map
getTimelineThemeColors = () => {
const isDark = document.body.classList.contains('quarto-dark');
if (isDark) {
return {
landColor: "#374151", // gray-700
countryStroke: "#6b7280", // gray-500
futurePath: "#60a5fa", // blue-400
traveledPath: "#f87171", // red-400
currentMarker: "#f87171", // red-400
otherMarkers: "#60a5fa", // blue-400
markerStroke: "#e5e7eb", // gray-200
labelText: "#f3f4f6", // gray-100
labelBg: "rgba(17, 24, 39, 0.8)", // gray-900 with opacity
tooltipBg: "rgba(17, 24, 39, 0.95)", // gray-900 with opacity
tooltipText: "#f3f4f6", // gray-100
tooltipAccent: "#fbbf24", // amber-400
controlsBg: "rgba(17, 24, 39, 0.8)", // gray-900 with opacity
controlsText: "#f3f4f6", // gray-100
controlsButtonBg: "rgba(75, 85, 99, 0.8)", // gray-600 with opacity
sliderBg: "rgba(17, 24, 39, 0.8)", // gray-900 with opacity
sliderText: "#f3f4f6", // gray-100
sliderButtonBg: "rgba(75, 85, 99, 0.8)" // gray-600 with opacity
};
} else {
return {
landColor: "#e5e7eb", // gray-200
countryStroke: "#9ca3af", // gray-400
futurePath: "#3b82f6", // blue-500
traveledPath: "#ef4444", // red-500
currentMarker: "#ef4444", // red-500
otherMarkers: "#3b82f6", // blue-500
markerStroke: "#374151", // gray-700
labelText: "#374151", // gray-700
labelBg: "rgba(255, 255, 255, 0.9)", // white with opacity
tooltipBg: "rgba(255, 255, 255, 0.95)", // white with opacity
tooltipText: "#374151", // gray-700
tooltipAccent: "#f59e0b", // amber-500
controlsBg: "rgba(255, 255, 255, 0.9)", // white with opacity
controlsText: "#374151", // gray-700
controlsButtonBg: "rgba(243, 244, 246, 0.8)", // gray-100 with opacity
sliderBg: "rgba(255, 255, 255, 0.9)", // white with opacity
sliderText: "#374151", // gray-700
sliderButtonBg: "rgba(243, 244, 246, 0.8)" // gray-100 with opacity
};
}
}
// Theme-adaptive year selector with buttons
viewof selectedYear = {
const MIN_YEAR = 1998;
const MAX_YEAR = 2025;
const DEFAULT_YEAR = 2025;
const colors = getTimelineThemeColors();
const container = html`<div style="display: flex; flex-direction: column; width: 100%;">
<div style="display: flex; align-items: center; margin-bottom: 8px;">
<button id="prev-year" style="
background: ${colors.sliderButtonBg};
color: ${colors.sliderText};
border: 1px solid ${colors.countryStroke};
border-radius: 4px;
padding: 4px 8px;
margin-right: 10px;
cursor: pointer;
font-size: 16px;
width: 36px;
">◀</button>
<div style="flex-grow: 1; text-align: center; color: ${colors.sliderText}; font-size: 16px;">
Year: <span id="year-display">${DEFAULT_YEAR}</span>
</div>
<button id="next-year" style="
background: ${colors.sliderButtonBg};
color: ${colors.sliderText};
border: 1px solid ${colors.countryStroke};
border-radius: 4px;
padding: 4px 8px;
margin-left: 10px;
cursor: pointer;
font-size: 16px;
width: 36px;
">▶</button>
</div>
<input type="range" id="year-slider" min="${MIN_YEAR}" max="${MAX_YEAR}" step="1" value="${DEFAULT_YEAR}" style="width: 100%; accent-color: ${colors.traveledPath};">
</div>`;
const slider = container.querySelector("#year-slider");
const display = container.querySelector("#year-display");
const prevBtn = container.querySelector("#prev-year");
const nextBtn = container.querySelector("#next-year");
let value = DEFAULT_YEAR;
// Function to update colors based on theme
const updateColors = () => {
const currentColors = getTimelineThemeColors();
prevBtn.style.background = currentColors.sliderButtonBg;
prevBtn.style.color = currentColors.sliderText;
prevBtn.style.borderColor = currentColors.countryStroke;
nextBtn.style.background = currentColors.sliderButtonBg;
nextBtn.style.color = currentColors.sliderText;
nextBtn.style.borderColor = currentColors.countryStroke;
display.parentElement.style.color = currentColors.sliderText;
slider.style.accentColor = currentColors.traveledPath;
};
// Function to update everything
const updateYear = (year) => {
year = Math.max(MIN_YEAR, Math.min(MAX_YEAR, year));
value = year;
slider.value = year;
display.textContent = year;
// Update button states
prevBtn.disabled = year <= MIN_YEAR;
prevBtn.style.opacity = year <= MIN_YEAR ? "0.5" : "1";
nextBtn.disabled = year >= MAX_YEAR;
nextBtn.style.opacity = year >= MAX_YEAR ? "0.5" : "1";
container.dispatchEvent(new CustomEvent("input"));
};
// Update when slider changes
slider.oninput = () => {
updateYear(parseInt(slider.value));
};
// Previous year button
prevBtn.onclick = () => {
updateYear(value - 1);
};
// Next year button
nextBtn.onclick = () => {
updateYear(value + 1);
};
// Add keyboard navigation
container.tabIndex = 0;
container.style.outline = "none";
container.addEventListener("keydown", (event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
updateYear(value - 1);
} else if (event.key === "ArrowRight") {
event.preventDefault();
updateYear(value + 1);
}
});
// Listen for theme changes
const observer = new MutationObserver(() => {
updateColors();
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
// Initialize
updateYear(DEFAULT_YEAR);
updateColors();
// Define getter for Observable
Object.defineProperty(container, "value", {
get: () => value
});
return container;
}
// Current location based on selected year
currentLocation = locationData.find(d => d.year === selectedYear)
// Create a theme-adaptive tooltip element
tooltip = {
const colors = getTimelineThemeColors();
const div = html`<div style="
position: absolute;
background: ${colors.tooltipBg};
border: 1px solid ${colors.countryStroke};
border-radius: 5px;
padding: 10px;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
z-index: 1000;
font-family: sans-serif;
font-size: 14px;
max-width: 200px;
color: ${colors.tooltipText};
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
"></div>`;
document.body.appendChild(div);
// Function to update tooltip colors
const updateTooltipColors = () => {
const currentColors = getTimelineThemeColors();
div.style.background = currentColors.tooltipBg;
div.style.borderColor = currentColors.countryStroke;
div.style.color = currentColors.tooltipText;
};
// Listen for theme changes
const observer = new MutationObserver(() => {
updateTooltipColors();
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
return div;
}
// Theme-adaptive map visualization
mapVisualization = {
const colors = getTimelineThemeColors();
// Set up dimensions and projection
const width = 900;
const height = 500;
// Create a projection focused on US and Europe
const projection = d3.geoMercator()
.center([-40, 45])
.scale(250)
.translate([width / 2, height / 2]);
// Create path generator
const path = d3.geoPath().projection(projection);
// Create SVG
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.attr("style", "max-width: 100%; height: auto;");
// Add ocean background
svg.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", "none");
// Create a group for all map elements
const g = svg.append("g");
// Draw countries
const countries = g.append("g")
.selectAll("path")
.data(world.features)
.join("path")
.attr("fill", colors.landColor)
.attr("stroke", colors.countryStroke)
.attr("stroke-width", 0.5)
.attr("d", path);
// Get unique locations to avoid drawing the same point multiple times
const uniqueLocations = Array.from(
new Map(locationData.map(d => [`${d.lat},${d.lon}`, d])).values()
).sort((a, b) => a.year - b.year);
// Draw path lines connecting unique locations
const lineGenerator = d3.line()
.x(d => projection([d.lon, d.lat])[0])
.y(d => projection([d.lon, d.lat])[1])
.curve(d3.curveCardinal.tension(0.5));
// Draw the complete path
const futurePath = g.append("path")
.attr("d", lineGenerator(uniqueLocations))
.attr("fill", "none")
.attr("stroke", colors.futurePath)
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("opacity", 0.7);
// Highlight the path traveled up to the current year
const currentYearIndex = uniqueLocations.findIndex(d => {
const locationsAtThisPoint = locationData.filter(loc =>
loc.lat === d.lat && loc.lon === d.lon
);
const years = locationsAtThisPoint.map(loc => loc.year);
const minYear = Math.min(...years);
return selectedYear < minYear;
});
let traveledPathIndex;
if (currentYearIndex === -1) {
traveledPathIndex = uniqueLocations.length;
} else if (currentYearIndex === 0) {
traveledPathIndex = 0;
} else {
traveledPathIndex = currentYearIndex;
}
// Draw traveled path
let traveledPath = null;
if (traveledPathIndex > 0) {
const traveledPathData = uniqueLocations.slice(0, traveledPathIndex);
traveledPath = g.append("path")
.attr("d", lineGenerator(traveledPathData))
.attr("fill", "none")
.attr("stroke", colors.traveledPath)
.attr("stroke-width", 3)
.attr("opacity", 0.9);
}
// Create a group for markers and labels
const markersGroup = g.append("g");
// Get unique locations for markers
const markerLocations = Array.from(
new Map(locationData.map(d => [`${d.lat},${d.lon}`, d])).values()
);
// Draw all locations as markers
const markers = markersGroup.selectAll("circle")
.data(markerLocations)
.join("circle")
.attr("cx", d => projection([d.lon, d.lat])[0])
.attr("cy", d => projection([d.lon, d.lat])[1])
.attr("r", d => {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
return isCurrentLocation ? 8 : 4;
})
.attr("fill", d => {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
return isCurrentLocation ? colors.currentMarker : colors.otherMarkers;
})
.attr("stroke", colors.markerStroke)
.attr("stroke-width", d => {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
return isCurrentLocation ? 2 : 1;
})
.attr("opacity", d => {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
return isCurrentLocation ? 1 : 0.7;
})
.on("mouseover", function(event, d) {
const locYears = locationData
.filter(loc => loc.lat === d.lat && loc.lon === d.lon)
.map(loc => loc.year);
const yearRanges = [];
let start = locYears[0];
let end = locYears[0];
for (let i = 1; i < locYears.length; i++) {
if (locYears[i] === end + 1) {
end = locYears[i];
} else {
yearRanges.push(start === end ? `${start}` : `${start}-${end}`);
start = end = locYears[i];
}
}
yearRanges.push(start === end ? `${start}` : `${start}-${end}`);
d3.select(this)
.attr("r", 12)
.attr("stroke-width", 3);
const currentColors = getTimelineThemeColors();
tooltip.innerHTML = `
<strong style="color: ${currentColors.tooltipAccent};">${d.location}, ${d.country}</strong><br/>
Years: ${yearRanges.join(", ")}<br/>
Coordinates: ${d.lat.toFixed(2)}, ${d.lon.toFixed(2)}
`;
tooltip.style.opacity = "1";
tooltip.style.left = (event.pageX + 15) + "px";
tooltip.style.top = (event.pageY - 28) + "px";
})
.on("mouseout", function(event, d) {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
d3.select(this)
.attr("r", isCurrentLocation ? 8 : 4)
.attr("stroke-width", isCurrentLocation ? 2 : 1);
tooltip.style.opacity = "0";
})
.on("mousemove", function(event) {
tooltip.style.left = (event.pageX + 15) + "px";
tooltip.style.top = (event.pageY - 28) + "px";
});
// Add label for current location
let currentLocationLabel = null;
let currentLocationBg = null;
if (currentLocation) {
const [x, y] = projection([currentLocation.lon, currentLocation.lat]);
currentLocationBg = markersGroup.append("rect")
.attr("x", x + 10)
.attr("y", y - 15)
.attr("width", `${currentLocation.location.length * 8 + 30}px`)
.attr("height", "22px")
.attr("fill", colors.labelBg)
.attr("rx", 3)
.attr("ry", 3);
currentLocationLabel = markersGroup.append("text")
.attr("x", x + 12)
.attr("y", y)
.text(`${currentLocation.location} (${currentLocation.year})`)
.attr("font-family", "sans-serif")
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("fill", colors.labelText);
}
// Add zoom controls
const zoomControls = svg.append("g")
.attr("transform", `translate(${width - 70}, 20)`);
// Zoom in button
const zoomInButton = zoomControls.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", 30)
.attr("height", 30)
.attr("fill", colors.controlsButtonBg)
.attr("stroke", colors.countryStroke)
.attr("rx", 5)
.attr("ry", 5)
.attr("cursor", "pointer")
.on("click", () => zoom.scaleBy(svg.transition().duration(300), 1.5));
const zoomInText = zoomControls.append("text")
.attr("x", 15)
.attr("y", 20)
.attr("text-anchor", "middle")
.attr("fill", colors.controlsText)
.attr("font-size", "18px")
.attr("font-weight", "bold")
.attr("pointer-events", "none")
.text("+");
// Zoom out button
const zoomOutButton = zoomControls.append("rect")
.attr("x", 0)
.attr("y", 40)
.attr("width", 30)
.attr("height", 30)
.attr("fill", colors.controlsButtonBg)
.attr("stroke", colors.countryStroke)
.attr("rx", 5)
.attr("ry", 5)
.attr("cursor", "pointer")
.on("click", () => zoom.scaleBy(svg.transition().duration(300), 0.75));
const zoomOutText = zoomControls.append("text")
.attr("x", 15)
.attr("y", 60)
.attr("text-anchor", "middle")
.attr("fill", colors.controlsText)
.attr("font-size", "18px")
.attr("font-weight", "bold")
.attr("pointer-events", "none")
.text("−");
// Reset button
const resetButton = zoomControls.append("rect")
.attr("x", 0)
.attr("y", 80)
.attr("width", 30)
.attr("height", 30)
.attr("fill", colors.controlsButtonBg)
.attr("stroke", colors.countryStroke)
.attr("rx", 5)
.attr("ry", 5)
.attr("cursor", "pointer")
.on("click", resetZoom);
const resetText = zoomControls.append("text")
.attr("x", 15)
.attr("y", 100)
.attr("text-anchor", "middle")
.attr("fill", colors.controlsText)
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("pointer-events", "none")
.text("R");
// Function to update all colors based on current theme
function updateMapColors() {
const currentColors = getTimelineThemeColors();
// Update countries
countries
.attr("fill", currentColors.landColor)
.attr("stroke", currentColors.countryStroke);
// Update paths
futurePath.attr("stroke", currentColors.futurePath);
if (traveledPath) {
traveledPath.attr("stroke", currentColors.traveledPath);
}
// Update markers
markers
.attr("fill", d => {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
return isCurrentLocation ? currentColors.currentMarker : currentColors.otherMarkers;
})
.attr("stroke", currentColors.markerStroke);
// Update labels
if (currentLocationBg) {
currentLocationBg.attr("fill", currentColors.labelBg);
}
if (currentLocationLabel) {
currentLocationLabel.attr("fill", currentColors.labelText);
}
// Update controls
zoomInButton.attr("fill", currentColors.controlsButtonBg).attr("stroke", currentColors.countryStroke);
zoomOutButton.attr("fill", currentColors.controlsButtonBg).attr("stroke", currentColors.countryStroke);
resetButton.attr("fill", currentColors.controlsButtonBg).attr("stroke", currentColors.countryStroke);
zoomInText.attr("fill", currentColors.controlsText);
zoomOutText.attr("fill", currentColors.controlsText);
resetText.attr("fill", currentColors.controlsText);
}
// Define zoom behavior
const zoom = d3.zoom()
.scaleExtent([0.5, 8])
.on("zoom", zoomed);
function zoomed(event) {
g.attr("transform", event.transform);
const strokeScale = 1 / Math.sqrt(event.transform.k);
g.selectAll("path").attr("stroke-width", 0.5 * strokeScale);
markersGroup.selectAll("circle").each(function(d) {
const isCurrentLocation = locationData.some(loc =>
loc.year === selectedYear &&
loc.lat === d.lat &&
loc.lon === d.lon
);
d3.select(this)
.attr("r", (isCurrentLocation ? 8 : 4) * strokeScale)
.attr("stroke-width", (isCurrentLocation ? 2 : 1) * strokeScale);
});
}
function resetZoom() {
svg.transition().duration(750).call(
zoom.transform,
d3.zoomIdentity
);
}
// Enable zoom and pan on the SVG
svg.call(zoom);
// Listen for theme changes and update colors accordingly
const observer = new MutationObserver(() => {
updateMapColors();
});
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
return svg.node();
}
// Display the map
mapVisualization;
locationData = Array(28) [Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, Object, …]
getTimelineThemeColors = ƒ()
Year: 2025
selectedYear = 2025
currentLocation = Object {year: 2025, location: "Irvine", country: "USA", lat: 33.6846, lon: -117.8265}
tooltip = HTMLDivElement {}