Public
Edited
May 12, 2024
4 stars
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
map = () => {
const wrapper = d3.create("div")
.style("position", "relative")
.style("width", `${mapwidth}px`)
.style("height", `${mapheight}px`);

wrapper.append("style").html(css);

const canvasStatic = wrapper.append("canvas")
.style("position", "absolute");

canvasStatic.node().width = mapwidth;
canvasStatic.node().height = mapheight;
const contextStatic = canvasStatic.node().getContext("2d");

path.context(contextStatic);
// States
contextStatic.beginPath();
contextStatic.fillStyle = "#f5f5f5";
contextStatic.strokeStyle = "#767676";
contextStatic.lineWidth = 0.5;
statesGeo.features.forEach(path);
contextStatic.fill();
contextStatic.stroke();

// Boundaries
if (showCurrent) {
contextStatic.beginPath();
contextStatic.fillStyle = colors.zoneCurrent;
zonesCurrentGeo.features.forEach(path);
contextStatic.fill();
}
if (showProposed) {
contextStatic.beginPath();
contextStatic.fillStyle = colors.zoneProposed;
zonesProposedGeo.features.forEach(path);
contextStatic.fill();
}
const canvas = wrapper.append("canvas")
.style("position", "absolute");

canvas.node().width = mapwidth;
canvas.node().height = mapheight;
const context = canvas.node().getContext("2d");
const svg = wrapper.append("svg")
.style("position", "absolute")
.attr("width", mapwidth)
.attr("height", mapheight);

svg.selectAll(".state-label")
.data(statesData)
.join("text")
.attr("class", "state-label")
.attr("transform", d => `translate(${projection([d.lon, d.lat])})`)
.text(d => d.name_abbr);

// whale
const whale = svg.append("g")
.attr("class", "whale")
.html(whaleString);
const tag = svg.append("circle")
.attr("fill", "#ffb371")
.attr("r", 2);

const scaleBar = svg.append("g")
.attr("class", "scale-bar")
.call(scaleBarGenerator);
scaleBar.select(".label")
.text(`${scaleBar.select(".tick:last-of-type text").text()} miles`);

return Object.assign(wrapper.node(), {
update(t) {
const coordinates = bishopInterpolator(t);
const projected = projection(coordinates);

context.clearRect(0, 0, mapwidth, mapheight);
path.context(context);
// Ships
// context.fillStyle = colors.vesselFaster;
segGrouped.forEach(ship => {
const curr = ship.position_interpolator(t);
const prev = ship.position_interpolator(t - interval);
if (curr) {
context.beginPath();
const [ x, y ] = projection(curr);
if (prev) {
const knots = radiansToNauticalMiles(d3.geoDistance(curr, prev)) * 3; // interval is every 20 minutes, knots is per hour
context.fillStyle = knots > 12 ? colors.vesselFaster : colors.vesselSlower;
const angle = geometric.lineAngle([[x, y], projection(prev)]);
const length = knots / 10;
context.moveTo(...geometric.pointTranslate([x, y], angle - 90, shipRadius));
context.lineTo(...geometric.pointTranslate([x, y], angle + 90, shipRadius));
context.lineTo(...geometric.pointTranslate([x, y], angle, length));
context.closePath();
context.fill()
}
else {
context.fillStyle = colors.vesselSlower;
}
context.arc(x, y, shipRadius, 0, Math.PI * 2);
context.fill();
}
});
// Journey
context.beginPath();
context.lineWidth = 1;
context.strokeStyle = "#202020";
const filtered = bishopGeo.features
.filter(leg => leg.properties.end.datetime.getTime() <= t);
if (filtered.length) {
// connect to whale
const journeyLast = filtered[filtered.length - 1];
if (journeyLast) {
const start = journeyLast.properties.end;
filtered.push({
type: "Feature",
geometry: {
type: "LineString",
coordinates: [
[start.lon, start.lat],
coordinates
]
}
});
}
filtered.forEach(path);
context.stroke();
}
path.context(null);

const whaleAngle = (() => {
if (t === bishopStart.getTime()){
const positionsNext = [0.1, 0.5, 1, 1.5, 2].map(n => {
const c = bishopInterpolator(t + interval * n);
return c[0] ? projection(c) : projected;
});
return d3.mean(positionsNext, d => geometric.lineAngle([d, projected]));
}
else {
const positionsPrev = [0.1, 0.5, 1, 1.5, 2].map(n => {
const c = bishopInterpolator(t - interval * n);
return c[0] ? projection(c) : projected;
});
return d3.mean(positionsPrev, d => geometric.lineAngle([projected, d]));
}
})();
whale
.attr("transform", `translate(${[projected[0] - whaleWidth * 0.5, projected[1] - whaleHeight * 0.5]}) rotate(${whaleAngle - 90}, ${whaleWidth * 0.5}, ${whaleHeight * 0.5})`);

tag
.attr("cx", d => projected[0])
.attr("cy", d => projected[1]);
}
});
}
Insert cell
display.update(time)
Insert cell
Insert cell
css = `
.whale {
stroke-width: 0.5px;
}
.whale .whale-black {
fill: #666;
stroke: #666;
}
.whale .whale-white {
fill: white;
stroke: #666;
}
.whale .whale-none {
fill: none;
stroke: none;
}
.scale-bar text {
font-family: ${franklinLight};
font-size: 14px;
}
.scale-bar .tick {
opacity: 0;
pointer-events: none;
}
.state-label {
font-family: ${franklinLight};
font-size: 14px;
fill: #767676;
letter-spacing: 1.5px;
paint-order: stroke fill;
stroke: #f5f5f5;
stroke-linejoin: round;
stroke-opacity: 0.6;
stroke-width: 4px;
text-anchor: middle;
text-transform: uppercase;
}
`
Insert cell
whaleWidth = 11.999999046325684
Insert cell
whaleHeight = 24.84000015258789
Insert cell
colors = ({
"vesselSlower": "#000000",
"vesselFaster": "#ff4f83",
"zoneCurrent": "#d3e7fa",
"zoneProposed": "#5784c5"
})
Insert cell
shipRadius = 2
Insert cell
whaleString = `<polyline class="whale-black" points="9.43,8.42 9.63,8.89 9.81,9.19 10.06,9.6 10.24,9.87 10.38,10.18 10.49,10.51 10.54,10.72
10.54,10.83 10.55,11.16 10.56,11.56 10.54,11.66 10.5,11.81 10.48,12.02 10.47,12.38 10.46,12.62 10.43,12.71 10.36,12.92
10.3,13.06 10.3,13.06 10.23,13.06 10.14,13 10.07,12.89 9.96,12.61 9.76,11.97 9.58,11.25 9.49,11.11 9.43,10.2 9.42,8.96
9.43,8.45 "/>
<polyline class="whale-black" points="2.89,8.42 2.69,8.89 2.51,9.19 2.26,9.6 2.08,9.87 1.94,10.18 1.83,10.51 1.78,10.72 1.78,10.83
1.77,11.16 1.76,11.56 1.78,11.66 1.82,11.81 1.84,12.02 1.85,12.38 1.86,12.62 1.89,12.71 1.96,12.92 2.02,13.06 2.02,13.06
2.09,13.06 2.18,13 2.26,12.89 2.36,12.61 2.56,11.97 2.74,11.25 2.83,11.11 2.89,10.2 2.9,8.96 2.89,8.45 "/>
<path class="whale-black" d="M3.12,7.56l0.33-1.51l0,0.01l0.41-1.87l0,0.01l0.28-1.21L4.13,3l0.31-1.17L4.43,1.84l0.39-1.09
c0.16-0.37,0.52-0.63,0.95-0.63H6l-0.02,0l0.33,0h0.23c0.43,0,0.79,0.26,0.95,0.63l0.39,1.09L7.87,1.83L8.18,3L8.17,2.99L8.45,4.2
l0-0.01l0.41,1.87l0-0.01l0.33,1.51L9.12,7.55c0.14,0,0.26,0.22,0.26,0.5L9.42,8.5l0.01,1.67l0-0.01l0.06,0.96l0.01,0.8l-0.04,0.6
l-0.07,0.62l-0.12,0.74l-0.19,0.84l-0.17,0.73L8.78,16c0,0-0.24,0.67-0.24,0.67l-0.38,0.87l-0.32,0.87l-0.13,0.35l-0.28,0.62
l-0.17,0.43l-0.14,0.4l-0.1,0.28l-0.08,0.19l-0.01,0.23l0.06,0.11l0.17,0.21l1.05,0.8l0.82,0.59l0.53,0.33l0.38,0.23l0.42,0.27
l0.39,0.24c0,0,0.31,0.22,0.31,0.22l0.44,0.33l0.37,0.3l0.26,0.2l0.03,0.07l-0.09,0.05l-0.11-0.01l-0.1,0.02l-0.07,0.04l0-0.01
l-0.05,0.06l-0.12-0.03l-0.42-0.18l-0.5-0.14l-0.59-0.13l-0.53-0.14l-0.53-0.09l-0.47-0.09L8,24.07l-0.58-0.09l-0.55-0.04
l-0.36-0.01L6.4,23.89l-0.08-0.05l-0.17-0.22h0l-0.17,0.22l-0.08,0.05L5.8,23.92l-0.36,0.01l-0.55,0.04l-0.58,0.09l-0.59,0.09
l-0.47,0.09l-0.53,0.09L2.2,24.48l-0.59,0.13c0,0-0.5,0.14-0.5,0.14L0.7,24.93l-0.12,0.03L0.53,24.9l0,0.01l-0.07-0.04l-0.1-0.02
l-0.11,0.01c0,0-0.09-0.05-0.09-0.05l0.03-0.07l0.26-0.2l0.37-0.3l0.44-0.33l0.31-0.22l0.39-0.24l0.42-0.27l0.38-0.23l0.53-0.33
l0.82-0.59l1.05-0.8l0.17-0.21c0,0,0.06-0.11,0.06-0.11l-0.01-0.23L5.3,20.5l-0.1-0.28l-0.14-0.4l-0.17-0.43L4.6,18.76l-0.13-0.35
l-0.32-0.87l-0.38-0.87L3.53,16l-0.14-0.54l-0.17-0.73l-0.19-0.84l-0.12-0.74l-0.07-0.62l-0.04-0.6l0.01-0.8l0.06-0.96l0,0.01
L2.89,8.5l0.04-0.45c0-0.27,0.12-0.5,0.26-0.5"/>
<path class="whale-white" d="M6.14,0.27l-0.22,0L5.78,0.3c0,0-0.13,0.04-0.13,0.04l-0.12,0.1L5.39,0.58L5.31,0.76L5.24,0.9L5.22,0.97l0,0.07
l-0.02,0l0.01,0.2L5.2,1.23l0.03,0.28c0,0,0.05,0.32,0.05,0.32l0.03,0.19L5.3,2l0.06,0.28l0.07,0.23l0.01,0.1l0.02,0.11l0.01,0.07
l0.01,0.15L5.5,3.14l0,0.22l0,0.39c0,0,0,0.17,0,0.17s-0.02,0.2-0.02,0.2L5.42,4.44l-0.03,0.2L5.36,4.83L5.3,5.02
c0,0-0.05,0.18-0.05,0.18L5.24,5.42L5.22,5.5L5.17,5.62L5.11,5.8L5.06,5.9L4.98,6.05L4.87,6.24L4.72,6.48L4.59,6.64L4.44,6.79
c0,0-0.12,0.12-0.12,0.12L4.21,7L4.1,7.06L4,7.12c0,0-0.16,0.08-0.16,0.08L3.71,7.22L3.64,7.24L3.58,7.28L3.49,7.33L3.41,7.38
L3.3,7.47L3.22,7.54L3.2,7.55L3.14,7.56l0.33-1.51l0,0.01l0.41-1.87l0,0.01l0.28-1.21L4.15,3l0.31-1.17L4.45,1.84l0.39-1.09
C5,0.39,5.36,0.13,5.79,0.13h0.23l0.02,0l0.1,0h0.04l0.1,0l0.02,0h0.23c0.43,0,0.79,0.26,0.95,0.63l0.39,1.09L7.87,1.83L8.18,3
L8.17,2.99L8.45,4.2l0-0.01l0.41,1.87l0-0.01l0.33,1.51L9.12,7.55L9.11,7.54L9.03,7.47L8.92,7.38L8.84,7.33L8.75,7.28
c0,0-0.06-0.04-0.06-0.04L8.62,7.22L8.49,7.19L8.33,7.12L8.22,7.06L8.12,7L8.01,6.91L7.88,6.79L7.74,6.64L7.61,6.48L7.46,6.24
L7.35,6.05L7.27,5.9L7.22,5.8L7.15,5.62L7.11,5.5L7.09,5.42L7.08,5.19L7.03,5.02L6.97,4.83L6.94,4.64l-0.03-0.2L6.85,4.12l-0.02-0.2
c0,0,0-0.17,0-0.17l0-0.39l0-0.22l0.02-0.21l0.01-0.15l0.01-0.07l0.02-0.11l0.01-0.1l0.07-0.23L7.02,2l0,0.02l0.03-0.19l0.05-0.32
l0.03-0.28L7.12,1.24l0.01-0.2l-0.02,0l0-0.07c0,0-0.02-0.07-0.02-0.07L7.02,0.76L6.94,0.58L6.8,0.44l-0.12-0.1
c0,0-0.13-0.04-0.13-0.04L6.4,0.28l-0.22,0"/>
<polyline class="whale-white" points="6.18,0.65 6.49,0.69 6.58,0.76 6.67,0.83 6.73,0.93 6.78,1.04 6.79,1.13 6.78,1.33 6.76,1.5
6.77,1.49 6.74,1.65 6.7,1.74 6.68,1.82 6.65,1.94 6.61,2.09 6.61,2.18 6.6,2.27 6.57,2.33 6.51,2.39 6.5,2.46 6.48,2.54 6.49,2.64
6.5,2.73 6.5,2.82 6.47,2.93 6.4,3.02 6.32,3.05 6.09,3.05 6,3.02 5.94,2.93 5.91,2.82 5.91,2.73 5.92,2.64 5.93,2.54 5.91,2.46
5.9,2.39 5.84,2.33 5.81,2.27 5.8,2.18 5.8,2.09 5.76,1.94 5.73,1.82 5.71,1.74 5.67,1.65 5.64,1.49 5.64,1.5 5.63,1.33 5.62,1.13
5.63,1.04 5.68,0.93 5.74,0.83 5.83,0.76 5.92,0.69 6.23,0.65 "/>`
Insert cell
Insert cell
projection = d3.geoMercator()
.fitExtent([[pad, pad], [mapwidth - pad, mapheight - pad]], boundsGeo);
Insert cell
path = d3.geoPath(projection)
Insert cell
scaleBarGenerator = d3.geoScaleBar()
.projection(projection)
.size([mapwidth, mapheight])
.left((16 / mapwidth))
.top(1 - (24 / mapheight))
.units(d3.geoScaleMiles)
.tickSize(null)
Insert cell
Insert cell
mapwidth = Math.min(640, width)
Insert cell
mapheight = mapwidth * aspect
Insert cell
aspect = 1.4
Insert cell
pad = 50
Insert cell
Insert cell
Insert cell
// Tagged right whale location data from NOAA Fisheries
whaleData = FileAttachment("NARW filtered Argos tracks_calving seasons 2015 and 2016_Movebank DAR output_27NOV2020.csv").csv()
Insert cell
// Filtered from Natural Earth 50m cultural vectors: https://www.naturalearthdata.com/downloads/50m-cultural-vectors/
statesTopo = FileAttachment("states.topo.json").json()
Insert cell
// AIS data from Tyler Clavelle at Global Fishing Watch, which also processed the data
shipZip = FileAttachment("20240429_harry_stevens.csv (1).zip").zip()
Insert cell
// https://www.fisheries.noaa.gov/resource/data/proposed-right-whale-seasonal-speed-zones
zonesProposedGeo = shpToGeo(await FileAttachment("Proposed-Right-Whale-Seasonal-Speed-Zones.zip").zip())
Insert cell
// From Eric M. Patterson, Ph.D., of NOAA Fisheries
// You can also log in and download from here: https://www.fisheries.noaa.gov/resource/map/north-atlantic-right-whale-seasonal-management-areas-sma
zonesCurrentGeo = shpToGeo(await FileAttachment("North_Atlantic_Right_Whale_Seasonal_Management_Areas.zip").zip())
Insert cell
Insert cell
interval = 60e3 * 20
Insert cell
Insert cell
bishopData = whaleData
.filter(d => d.WhaleID === "Eg4445")
.map((d, i) => {
return {
// frame: i,
lon: +d["location-long"],
lat: +d["location-lat"],
datetime: new Date(`${d.timestamp.replace(" ", "T")}Z`)
}
})
Insert cell
bishopStart = d3.min(bishopData, d => d.datetime)
Insert cell
bishopEnd = d3.max(bishopData, d => d.datetime)
Insert cell
bishopRange = bishopEnd.getTime() - bishopStart.getTime()
Insert cell
bishopInterpolator = pathInterpolate(bishopData, bishopStart.getTime(), bishopEnd.getTime())
Insert cell
bishopGeo = ({
type: "FeatureCollection",
features: bishopData
.map((start, i) => {
if (i === bishopData.length - 1) return;
const end = bishopData[i + 1];
return {
type: "Feature",
properties: {
start,
end
},
geometry: {
type: "LineString",
coordinates: [
[start.lon, start.lat],
[end.lon, end.lat]
]
}
}
})
.filter(d => d)
})
Insert cell
Insert cell
statesGeo = topojson.feature(statesTopo, statesTopo.objects.states)
Insert cell
statesData = [
{
name: "Connecticut",
name_abbr: "Conn.",
name_postal: "CT",
lon: -72.649234,
lat: 41.5
},
{
name: "Delaware",
name_abbr: "Del.",
name_postal: "DE",
lon: -75.441883,
lat: 38.803258
},
{
name: "Florida",
name_abbr: "Fla.",
name_postal: "FL",
lon: -82.277997,
lat: 29.639664,
},
{
name: "Georgia",
name_abbr: "Ga.",
name_postal: "GA",
lon: -83.376207,
lat: 32.613036,
},
{
name: "Maryland",
name_abbr: "Md.",
name_postal: "MD",
lon: -77.023139,
lat: 39.3
},
{
name: "Massachusetts",
name_abbr: "Mass.",
name_postal: "MA",
lon: -72.1,
lat: 42.2,
},
{
name: "New Jersey",
name_abbr: "N.J.",
name_postal: "NJ",
lon: -74.64,
lat: 39.691160
},
{
name: "North Carolina",
name_abbr: "N.C.",
name_postal: "NC",
lon: -79.049846,
lat: 35.5
},
{
name: "Rhode Island",
name_abbr: "R.I.",
name_postal: "RI",
lon: -71.566889,
lat: 41.55
},
{
name: "South Carolina",
name_abbr: "S.C.",
name_postal: "SC",
lon: -80.764230,
lat: 33.9,
},
{
name: "Virginia",
name_abbr: "Va.",
name_postal: "VA",
lon: -78.768774,
lat: 37.259825
}
]
Insert cell
Insert cell
shipDataNums = ["lat", "lon", "speed_knots", "best_length_m"]
Insert cell
shipData = (await shipZip.file("20240429_harry_stevens.csv").csv())
.map(d => {
shipDataNums.forEach(c => {
d[c] = +d[c];
d.datetime = new Date(d.timestamp);
});
return d;
})
Insert cell
avgKnots = 2 // filter out slowest segments
Insert cell
length = 5 // filter out short segments
Insert cell
minHours = 24 // filter for ships that were close in a certain time period
Insert cell
minMiles = 10 // filter for ships that were close to bishop
Insert cell
minBishopDist = minMiles / (54.6 * 2) // degrees longitude
Insert cell
// Group the data by segments and filter out segments that are too short
segGrouped = {
const data = d3.groups(shipData, d => d.seg_id)
.map(([seg_id, entries]) => {
const [lon_min, lon_max] = d3.extent(entries, d => d.lon)
const [lat_min, lat_max] = d3.extent(entries, d => d.lat)
const [ms_min, ms_max] = d3.extent(entries, d => d.datetime.getTime())
let closest_distance = Infinity;
const bishopInRange = bishopData.filter(d => d.datetime.getTime() >= ms_min && d.datetime.getTime() <= ms_max)
const bishopTree = new BishopBush()
bishopTree.load(bishopInRange);
const entriesInRange = entries
.map(d => {
const bishops = bishopTree.search({
minX: d.lon - minBishopDist,
minY: d.lat - minBishopDist,
maxX: d.lon + minBishopDist,
maxY: d.lat + minBishopDist
});
if (bishops.length) {
return bishops.map(d0 => {
const ms = Math.abs(d0.datetime.getTime() - d.datetime.getTime());
return {
hours: ms / (1000 * 60 * 60),
miles: 3959 * d3.geoDistance([d0.lon, d0.lat], [d.lon, d.lat])
}
})
} else {
return null;
}
})
.filter(d => d)
.flat()
.filter(d0 => d0.hours <= minHours)
return {
seg_id,
entries,
avg_knots: d3.mean(entries, d => d.speed_knots),
max_knots: d3.max(entries, d => d.speed_knots),
length: geometric.polygonLength(entries.map(d => [d.lon, d.lat])),
ms_min,
ms_max,
entriesInRange,
position_interpolator: pathInterpolate(entries)
}
})
.sort((a, b) => d3.descending(a.length, b.length))

if (showAllShips) {
return data;
}
else {
return data
.filter(d => d.entriesInRange.length > 0)
}
}
Insert cell
// An RBush to index Bishop's position data
class BishopBush extends RBush {
toBBox(d) { return {minX: d.lon, minY: d.lat, maxX: d.lon, maxY: d.lat}; }
compareMinX(a, b) { return a.lon - b.lon; }
compareMinY(a, b) { return a.lat - b.lat; }
}
Insert cell
Insert cell
bounds = d3.geoBounds(zonesProposedGeo);
Insert cell
boundsGeo = ({
type: "Polygon",
coordinates: [[
bounds[0],
[bounds[0][0], bounds[1][1]],
bounds[1],
[bounds[1][0], bounds[0][1]],
bounds[0]
]]
})
Insert cell
Insert cell
function radiansToNauticalMiles(radians) {
const earthRadiusKm = 6371; // Earth's average radius in kilometers
const nauticalMileInMeters = 1852; // One nautical mile in meters
const nauticalMilesPerRadian = 2 * Math.PI * earthRadiusKm * 1000 / nauticalMileInMeters;
return radians * nauticalMilesPerRadian;
}
Insert cell
function createInterpolator(accessor, interpolator){
return (path) => {
const [minTime, maxTime] = d3.extent(path, d => d.datetime.getTime());
return (time) => {
if (time < minTime) {
return null;
}
if (time > maxTime) {
return null;
}
// Find the segment
let segmentStart, segmentEnd, segmentT;
for (let i = 0, l = path.length - 1; i < l; i++) {
const start = path[i], end = path[i + 1];
const startTime = start.datetime.getTime();
const endTime = end.datetime.getTime();
if (time >= startTime && time <= endTime) {
segmentStart = accessor(start);
segmentEnd = accessor(end);
segmentT = (time - startTime) / (endTime - startTime);
break;
}
}
if (!segmentStart || !segmentEnd) {
return null;
}
return interpolator(segmentStart, segmentEnd)(segmentT);
}
}
}
Insert cell
pathInterpolate = createInterpolator(
d => [d.lon, d.lat],
(a, b) => geometric.lineInterpolate([a, b])
)
Insert cell
Insert cell
import { franklinLight } from "@climatelab/fonts@46"
Insert cell
import {Scrubber} from "@mbostock/scrubber@255"
Insert cell
import { shpToGeo } from "@climatelab/shapefile-to-geojson@16";
Insert cell
import { toc } from "@climatelab/toc@45"
Insert cell
d3 = require("d3@7", "d3-geo-scale-bar@1")
Insert cell
geometric = require("geometric@2")
Insert cell
RBush = require("rbush@3");
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more